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:
parent
98cf36fc51
commit
0bdf13816d
6 changed files with 562 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue