mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
asset viewer now viewing on tabs
Tabs is better for this yay Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : DetailSectionCard.jsx
|
||||
* Type : Component (Generic)
|
||||
* Description : Stacked-card building blocks for tabbed detail pages —
|
||||
* a bordered card with an icon/title header (SectionCard) and
|
||||
* a label/value pair that falls back to "—" when empty
|
||||
* (InfoRow). Pulled out of ViewPlan.jsx (modules/admin/pages/
|
||||
* tiers/ViewPlan.jsx) so the admin asset view pages can reuse
|
||||
* the same look without duplicating it four times.
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{children ?? <span className="text-muted-foreground italic">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionCard({ icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : DetailTabsHeader.jsx
|
||||
* Type : Component (Generic)
|
||||
* Description : Sticky header + underline tab row for tabbed detail pages —
|
||||
* back button, icon/title/subtitle, a right-aligned actions
|
||||
* slot, and an underline tab row. Parameterized version of the
|
||||
* header in ViewPlan.jsx (modules/admin/pages/tiers/
|
||||
* ViewPlan.jsx), reused by the admin asset view pages so they
|
||||
* don't each duplicate the same markup.
|
||||
*
|
||||
* Props:
|
||||
* icon {Component} – lucide icon rendered before the title
|
||||
* title {string}
|
||||
* subtitle {string}
|
||||
* actions {ReactNode} – right-aligned buttons (e.g. Edit)
|
||||
* onBack {function}
|
||||
* tabs {Array<{ key, label, icon }>}
|
||||
* activeTab {string}
|
||||
* onTabChange{function}
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function DetailTabsHeader({
|
||||
icon: Icon, title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
{/* Underline tabs */}
|
||||
<div className="flex gap-1 pb-0 -mb-px">
|
||||
{tabs.map(({ key, label, icon: TabIcon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onTabChange(key)}
|
||||
className={[
|
||||
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === key
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<TabIcon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
* mimeType {string}
|
||||
* fileName {string}
|
||||
* loading {boolean} – true while the parent is still resolving the src
|
||||
* canvasClassName {string} – class(es) controlling the viewer canvas height,
|
||||
* default 'h-[420px]'. Pass e.g. 'flex-1 min-h-[500px]'
|
||||
* to stretch the canvas to fill a parent's height.
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -95,7 +98,7 @@ const ZoomToolbar = ({
|
||||
|
||||
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
|
||||
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
|
||||
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
|
||||
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, canvasClassName, children }) => {
|
||||
const containerRef = useRef(null);
|
||||
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
|
||||
|
||||
@@ -139,7 +142,8 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
|
||||
'relative overflow-hidden bg-muted flex items-center justify-center',
|
||||
canvasClassName,
|
||||
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
||||
)}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -161,14 +165,14 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
|
||||
};
|
||||
|
||||
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
||||
const ImageViewer = ({ src, fileName }) => {
|
||||
const ImageViewer = ({ src, fileName, canvasClassName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
@@ -179,7 +183,7 @@ const ImageViewer = ({ src, fileName }) => {
|
||||
onPrevPage={() => {}}
|
||||
onNextPage={() => {}}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit} canvasClassName={canvasClassName}>
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
@@ -193,7 +197,7 @@ const ImageViewer = ({ src, fileName }) => {
|
||||
};
|
||||
|
||||
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
||||
const PdfViewer = ({ src, fileName }) => {
|
||||
const PdfViewer = ({ src, fileName, canvasClassName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [pdfDoc, setPdfDoc] = useState(null);
|
||||
@@ -258,7 +262,7 @@ const PdfViewer = ({ src, fileName }) => {
|
||||
if (loadError) return <UnsupportedMessage fileName={fileName} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
@@ -269,7 +273,7 @@ const PdfViewer = ({ src, fileName }) => {
|
||||
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
|
||||
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit} canvasClassName={canvasClassName}>
|
||||
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
|
||||
</ZoomPanArea>
|
||||
{rendering && (
|
||||
@@ -282,12 +286,12 @@ const PdfViewer = ({ src, fileName }) => {
|
||||
};
|
||||
|
||||
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
||||
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
||||
const FileZoomViewer = ({ src, mimeType, fileName, loading, canvasClassName = 'h-[420px]' }) => {
|
||||
const mode = resolveMode(mimeType, fileName);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[420px]">
|
||||
<div className={cn('flex items-center justify-center', canvasClassName)}>
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
@@ -297,8 +301,8 @@ const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
|
||||
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
|
||||
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} canvasClassName={canvasClassName} />;
|
||||
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} canvasClassName={canvasClassName} />;
|
||||
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Music2, Pencil } from "lucide-react";
|
||||
import { Lock, Globe, Music2, Pencil, Info } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
@@ -9,24 +10,16 @@ import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { formatFileSize } from "@/utils/format.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||
|
||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
const TABS = [
|
||||
{ key: "preview", label: "Preview", icon: Music2 },
|
||||
{ key: "info", label: "File Info", icon: Info },
|
||||
];
|
||||
|
||||
export default function ViewAudioAsset() {
|
||||
const { assetId } = useParams();
|
||||
@@ -35,6 +28,7 @@ export default function ViewAudioAsset() {
|
||||
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
@@ -60,75 +54,82 @@ export default function ViewAudioAsset() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<DetailTabsHeader
|
||||
icon={Music2}
|
||||
title={a.display_name ?? a.original_name}
|
||||
subtitle={a.mime_type}
|
||||
onBack={() => navigate("/admin/assets")}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
{/* ── Audio player ── */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{streamUrl ? (
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
{activeTab === "preview" ? (
|
||||
streamUrl ? (
|
||||
<AudioBlock content={audioContent} />
|
||||
) : (
|
||||
<MediaFallback className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
<SectionCard icon={Info} title="File Info">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||
<InfoRow label="MIME Type">{a.mime_type}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Metadata panel ── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
<SectionCard icon={Music2} title="Storage">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Lock} title="Access">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Info} title="Timestamps">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<SectionCard icon={Info} title="Description">
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, FileText, Pencil } from "lucide-react";
|
||||
import { Lock, Globe, FileText, Pencil, Info } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
@@ -9,22 +10,18 @@ import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { formatFileSize } from "@/utils/format.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||
|
||||
const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
||||
|
||||
const TABS = [
|
||||
{ key: "preview", label: "Preview", icon: FileText },
|
||||
{ key: "info", label: "File Info", icon: Info },
|
||||
];
|
||||
|
||||
export default function ViewDocumentAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -32,6 +29,7 @@ export default function ViewDocumentAsset() {
|
||||
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
@@ -52,34 +50,33 @@ export default function ViewDocumentAsset() {
|
||||
const isPdf = ext === "pdf";
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<DetailTabsHeader
|
||||
icon={FileText}
|
||||
title={a.display_name ?? a.original_name}
|
||||
subtitle={a.mime_type}
|
||||
onBack={() => navigate("/admin/assets")}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
{/* ── Document preview ── */}
|
||||
<div className="lg:col-span-3">
|
||||
{isPdf ? (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className={`lg:container lg:mx-auto lg:px-6 px-4 py-6 flex flex-col${activeTab === "preview" ? " flex-1 min-h-0" : ""}`}>
|
||||
{activeTab === "preview" ? (
|
||||
isPdf ? (
|
||||
<div className="rounded-lg border overflow-hidden flex flex-col flex-1 min-h-0">
|
||||
<FileZoomViewer
|
||||
src={streamUrl}
|
||||
mimeType={a.mime_type}
|
||||
fileName={a.display_name ?? a.original_name}
|
||||
loading={previewLoading}
|
||||
canvasClassName="flex-1 min-h-[500px]"
|
||||
/>
|
||||
</div>
|
||||
) : canPreview && streamUrl ? (
|
||||
@@ -98,49 +95,59 @@ export default function ViewDocumentAsset() {
|
||||
Preview not available for this file type.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
<SectionCard icon={Info} title="File Info">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||
<InfoRow label="MIME Type">{a.mime_type}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Metadata panel ── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
<SectionCard icon={FileText} title="Storage">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Lock} title="Access">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Info} title="Timestamps">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<SectionCard icon={Info} title="Description">
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Pencil, Download } from "lucide-react";
|
||||
import { Image, Info, Lock, Globe, Pencil, Download } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
@@ -11,20 +11,15 @@ import { formatFileSize } from "@/utils/format.util";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TABS = [
|
||||
{ key: "preview", label: "Preview", icon: Image },
|
||||
{ key: "info", label: "File Info", icon: Info },
|
||||
];
|
||||
|
||||
export default function ViewImageAsset() {
|
||||
const { assetId } = useParams();
|
||||
@@ -34,6 +29,7 @@ export default function ViewImageAsset() {
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true);
|
||||
@@ -60,79 +56,92 @@ export default function ViewImageAsset() {
|
||||
const a = selectedAsset;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<DetailTabsHeader
|
||||
icon={Image}
|
||||
title={a.display_name ?? a.original_name}
|
||||
subtitle={a.mime_type}
|
||||
onBack={() => navigate("/admin/assets")}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="hidden" onClick={handleDownload} disabled={downloading}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{downloading ? "Downloading…" : "Download"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="hidden" onClick={handleDownload} disabled={downloading}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{downloading ? "Downloading…" : "Download"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
{/* ── Image preview ── */}
|
||||
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
|
||||
<FileZoomViewer
|
||||
src={streamUrl}
|
||||
mimeType={a.mime_type}
|
||||
fileName={a.display_name ?? a.original_name}
|
||||
loading={previewLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Metadata panel ── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
||||
<MetaRow label="Resolution" value={a.resolution} />
|
||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
<div className={`lg:container lg:mx-auto lg:px-6 px-4 py-6 flex flex-col${activeTab === "preview" ? " flex-1 min-h-0" : ""}`}>
|
||||
{activeTab === "preview" ? (
|
||||
<div className="rounded-lg border overflow-hidden flex flex-col flex-1 min-h-0">
|
||||
<FileZoomViewer
|
||||
src={streamUrl}
|
||||
mimeType={a.mime_type}
|
||||
fileName={a.display_name ?? a.original_name}
|
||||
loading={previewLoading}
|
||||
canvasClassName="flex-1 min-h-[500px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
<SectionCard icon={Info} title="File Info">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||
<InfoRow label="Resolution">{a.resolution}</InfoRow>
|
||||
<InfoRow label="Dimensions">{a.width && a.height ? `${a.width} × ${a.height}` : null}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionCard icon={Image} title="Storage">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Lock} title="Access">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Info} title="Timestamps">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<SectionCard icon={Info} title="Description">
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Pencil } from "lucide-react";
|
||||
import { Lock, Globe, Pencil, Video, Info } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
import { formatFileSize, formatPlayerTime } from "@/utils/format.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
|
||||
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
import DetailTabsHeader from "@/components/generic/DetailTabsHeader";
|
||||
import { SectionCard, InfoRow } from "@/components/generic/DetailSectionCard";
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TABS = [
|
||||
{ key: "preview", label: "Preview", icon: Video },
|
||||
{ key: "info", label: "File Info", icon: Info },
|
||||
];
|
||||
|
||||
export default function ViewVideoAsset() {
|
||||
const { assetId } = useParams();
|
||||
@@ -29,6 +26,7 @@ export default function ViewVideoAsset() {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
@@ -46,102 +44,112 @@ export default function ViewVideoAsset() {
|
||||
const a = selectedAsset;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<DetailTabsHeader
|
||||
icon={Video}
|
||||
title={a.display_name ?? a.original_name}
|
||||
subtitle={a.mime_type}
|
||||
onBack={() => navigate("/admin/assets")}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Info
|
||||
</Button>
|
||||
</div>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
{activeTab === "preview" ? (
|
||||
<div className="space-y-3">
|
||||
<TranscodeStatusBanner status={a.transcode_status} />
|
||||
<VideoBlock
|
||||
readOnly
|
||||
onUpdate={() => {}}
|
||||
content={{
|
||||
asset_id: a.asset_id,
|
||||
storage_provider: a.storage_provider,
|
||||
url: a.file_url,
|
||||
thumbnail_url: a.thumbnail_url,
|
||||
title: a.display_name ?? a.original_name,
|
||||
tag: a.extension?.toUpperCase() ?? "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
{/* ── Video player ── */}
|
||||
<div className="lg:col-span-3 space-y-3">
|
||||
<TranscodeStatusBanner status={a.transcode_status} />
|
||||
<VideoBlock
|
||||
readOnly
|
||||
onUpdate={() => {}}
|
||||
content={{
|
||||
asset_id: a.asset_id,
|
||||
storage_provider: a.storage_provider,
|
||||
url: a.file_url,
|
||||
thumbnail_url: a.thumbnail_url,
|
||||
title: a.display_name ?? a.original_name,
|
||||
tag: a.extension?.toUpperCase() ?? "",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{a.thumbnail_url && (
|
||||
<div className="rounded-lg border overflow-hidden bg-muted/30">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-3 py-2">Thumbnail</p>
|
||||
<img
|
||||
src={a.thumbnail_url}
|
||||
alt="Thumbnail"
|
||||
className="w-full max-h-40 object-cover"
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Metadata panel ── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Video Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
||||
<MetaRow label="Resolution" value={a.resolution} />
|
||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||
<MetaRow label="Duration" value={a.duration == null ? null : formatPlayerTime(a.duration)} />
|
||||
<MetaRow label="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
|
||||
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
|
||||
<MetaRow label="Video Codec" value={a.video_codec} />
|
||||
<MetaRow label="Audio Codec" value={a.audio_codec} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
{/* Thumbnail strip */}
|
||||
{a.thumbnail_url && (
|
||||
<div className="rounded-lg border overflow-hidden bg-muted/30">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-3 py-2">Thumbnail</p>
|
||||
<img
|
||||
src={a.thumbnail_url}
|
||||
alt="Thumbnail"
|
||||
className="w-full max-h-40 object-cover"
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
<SectionCard icon={Info} title="Video Info">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Original Name">{a.original_name}</InfoRow>
|
||||
<InfoRow label="Extension">{a.extension}</InfoRow>
|
||||
<InfoRow label="File Size">{formatFileSize(a.file_size)}</InfoRow>
|
||||
<InfoRow label="Resolution">{a.resolution}</InfoRow>
|
||||
<InfoRow label="Dimensions">{a.width && a.height ? `${a.width} × ${a.height}` : null}</InfoRow>
|
||||
<InfoRow label="Duration">{a.duration == null ? null : formatPlayerTime(a.duration)}</InfoRow>
|
||||
<InfoRow label="Frame Rate">{a.frame_rate ? `${a.frame_rate} fps` : null}</InfoRow>
|
||||
<InfoRow label="Bitrate">{a.bitrate ? `${a.bitrate} kbps` : null}</InfoRow>
|
||||
<InfoRow label="Video Codec">{a.video_codec}</InfoRow>
|
||||
<InfoRow label="Audio Codec">{a.audio_codec}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionCard icon={Video} title="Storage">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Provider">{a.storage_provider}</InfoRow>
|
||||
<InfoRow label="Bucket">{a.storage_bucket}</InfoRow>
|
||||
<InfoRow label="Key">{a.storage_key}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Lock} title="Access">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Access Level">{a.access_level}</InfoRow>
|
||||
<InfoRow label="Owner Type">{a.owner_type}</InfoRow>
|
||||
<InfoRow label="Owner ID">{a.owner_id}</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Info} title="Timestamps">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created By">{a.creator?.full_name ?? a.createdBy}</InfoRow>
|
||||
<InfoRow label="Created">{a.createdAt ? fmtDateTime(a.createdAt) : null}</InfoRow>
|
||||
<InfoRow label="Modified By">{a.updater?.full_name ?? a.updatedBy}</InfoRow>
|
||||
<InfoRow label="Modified">{a.updatedAt ? fmtDateTime(a.updatedAt) : null}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{a.description && (
|
||||
<SectionCard icon={Info} title="Description">
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user