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