From 0bdf13816dbd7d2edbb523efc44dfaa539c6cfbb Mon Sep 17 00:00:00 2001 From: glass beadian Date: Sat, 17 Oct 2026 12:58:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Files=20media=20viewer=20=E2=80=94=20br?= =?UTF-8?q?and=20video=20player,=20image=20lightbox,=20media=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/app/(main)/files/page.tsx | 32 +++ apps/web/src/components/files/files-client.ts | 8 +- apps/web/src/components/files/media-thumb.tsx | 80 ++++++++ .../web/src/components/files/media-viewer.tsx | 189 ++++++++++++++++++ .../web/src/components/files/video-player.tsx | 184 +++++++++++++++++ apps/web/src/lib/media-cache.ts | 70 +++++++ 6 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/files/media-thumb.tsx create mode 100644 apps/web/src/components/files/media-viewer.tsx create mode 100644 apps/web/src/components/files/video-player.tsx create mode 100644 apps/web/src/lib/media-cache.ts diff --git a/apps/web/src/app/(main)/files/page.tsx b/apps/web/src/app/(main)/files/page.tsx index b93ce4e..628cdcc 100644 --- a/apps/web/src/app/(main)/files/page.tsx +++ b/apps/web/src/app/(main)/files/page.tsx @@ -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(null); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); @@ -368,6 +372,34 @@ export default function FilesPage(): React.ReactElement { )} {showList && items.length > 0 && ( +
+ + +
+ )} + + {showList && view === "grid" && items.length > 0 && (() => { + const mediaItems = items.filter((item) => item.mimeClass === "image" || item.mimeClass === "video"); + return ( + <> +
+ {mediaItems.map((item) => ( + setViewerIndex(mediaItems.findIndex((m) => m.id === item.id))} /> + ))} +
+ {items.some((item) => item.mimeClass !== "image" && item.mimeClass !== "video") && ( +

+ {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." })} +

+ )} + {viewerIndex !== null && mediaItems[viewerIndex] !== undefined && ( + setViewerIndex(null)} onIndex={setViewerIndex} /> + )} + + ); + })()} + + {showList && view === "list" && items.length > 0 && (
{items.map((item) => ( { * same base URL, Bearer token, cookie credentials — then streams the body * into a Blob and clicks a temporary anchor. */ -export async function downloadGroupFile(itemId: string, fileName: string): Promise { +/** The decrypted payload as a Blob (viewer/thumbnail source; downloads save it). */ +export async function downloadGroupFileBlob(itemId: string, mimeType = "application/octet-stream"): Promise { const response = await fetch(`${API_URL}/files/${encodeURIComponent(itemId)}/download`, { credentials: "include", headers: authHeaders(), @@ -23,6 +24,11 @@ export async function downloadGroupFile(itemId: string, fileName: string): Promi throw new ApiError(response.status, `Download failed (HTTP ${response.status})`, null); } const blob = await response.blob(); + return blob.type === "" ? new Blob([blob], { type: mimeType }) : blob; +} + +export async function downloadGroupFile(itemId: string, fileName: string): Promise { + const blob = await downloadGroupFileBlob(itemId); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; diff --git a/apps/web/src/components/files/media-thumb.tsx b/apps/web/src/components/files/media-thumb.tsx new file mode 100644 index 0000000..65bc35c --- /dev/null +++ b/apps/web/src/components/files/media-thumb.tsx @@ -0,0 +1,80 @@ +"use client"; + +/** + * Grid thumbnail for one media item: decrypts once through the session cache, + * renders images directly, and captures a frame from videos (hidden