feat: Files media viewer — brand video player, image lightbox, media grid

SOTA lightbox patterns adapted to the hub: images zoom (wheel/double-click)
pan rotate with a filmstrip + counter + focus trap; videos play in a
brand-skinned player (scrubber with buffered range, volume, speed, PiP,
fullscreen, keyboard: space/k, arrows ±5s, j/l ±10s, m, f, p, up/down).
Payloads are E2E-encrypted, so thumbnails and playback decrypt client-side
through the session cache (direct objects via presigned GET + CEK; others
via the streaming route) — the grid shows real video stills captured in the
browser. List view stays for documents; Media/Grid is the default for
image/video items. FileRow gains a View action for media rows.
This commit is contained in:
glass beadian 2026-10-17 12:58:00 +00:00
parent 98cf36fc51
commit 0bdf13816d
6 changed files with 562 additions and 1 deletions

View file

@ -10,6 +10,8 @@ import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client"
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { FileRow } from "../../../components/files/file-row";
import { MediaThumb } from "../../../components/files/media-thumb";
import { MediaViewer } from "../../../components/files/media-viewer";
import { UploadDropzone } from "../../../components/files/upload-dropzone";
import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog";
@ -82,6 +84,8 @@ export default function FilesPage(): React.ReactElement {
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [view, setView] = useState<"list" | "grid">("grid");
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@ -368,6 +372,34 @@ export default function FilesPage(): React.ReactElement {
)}
{showList && items.length > 0 && (
<div className="mb-3 flex items-center gap-1" role="tablist" aria-label={t({ en: "View", es: "Vista" })}>
<Button size="sm" variant={view === "grid" ? "primary" : "outline"} aria-pressed={view === "grid"} onClick={() => setView("grid")}>{t({ en: "Media", es: "Medios" })}</Button>
<Button size="sm" variant={view === "list" ? "primary" : "outline"} aria-pressed={view === "list"} onClick={() => setView("list")}>{t({ en: "List", es: "Lista" })}</Button>
</div>
)}
{showList && view === "grid" && items.length > 0 && (() => {
const mediaItems = items.filter((item) => item.mimeClass === "image" || item.mimeClass === "video");
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{mediaItems.map((item) => (
<MediaThumb key={item.id} item={item} onOpen={() => setViewerIndex(mediaItems.findIndex((m) => m.id === item.id))} />
))}
</div>
{items.some((item) => item.mimeClass !== "image" && item.mimeClass !== "video") && (
<p className="mt-3 text-2xs text-text-faint">
{t({ en: "Media view shows images and videos — switch to List for documents and archives.", es: "La vista de medios muestra imágenes y vídeos — cambia a Lista para documentos y archivos." })}
</p>
)}
{viewerIndex !== null && mediaItems[viewerIndex] !== undefined && (
<MediaViewer items={mediaItems} index={viewerIndex} onClose={() => setViewerIndex(null)} onIndex={setViewerIndex} />
)}
</>
);
})()}
{showList && view === "list" && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<FileRow