mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: modality-aware file preview in Files library
- Video files: inline video player with controls - Audio files: waveform visualization with playback (wavesurfer.js) - Image files: thumbnail preview (existing behavior) - Documents/data: file type icon with format label
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import { FileImage, ImageIcon } from "lucide-react";
|
import { FileImage, FileText, ImageIcon, Music, Video } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,10 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
|
const WaveformPlayer = lazy(() =>
|
||||||
|
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
|
||||||
|
);
|
||||||
|
|
||||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -60,6 +64,78 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
|||||||
return <img src={blobUrl} alt={alt} className={className} />;
|
return <img src={blobUrl} alt={alt} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilePreview({
|
||||||
|
fileId,
|
||||||
|
mimeType,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
fileId: string;
|
||||||
|
mimeType: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const downloadUrl = getFileDownloadUrl(fileId);
|
||||||
|
const headers = formatHeaders();
|
||||||
|
const [mediaSrc, setMediaSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mimeType.startsWith("video/") && !mimeType.startsWith("audio/")) return;
|
||||||
|
let revoked = false;
|
||||||
|
fetch(downloadUrl, { headers })
|
||||||
|
.then((res) => res.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
if (!revoked) setMediaSrc(URL.createObjectURL(blob));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
revoked = true;
|
||||||
|
if (mediaSrc) URL.revokeObjectURL(mediaSrc);
|
||||||
|
};
|
||||||
|
}, [fileId]);
|
||||||
|
|
||||||
|
if (mimeType.startsWith("video/")) {
|
||||||
|
return mediaSrc ? (
|
||||||
|
<video src={mediaSrc} controls className="w-full rounded-lg max-h-48 bg-black">
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Video className="h-8 w-8 text-muted-foreground animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType.startsWith("audio/")) {
|
||||||
|
return mediaSrc ? (
|
||||||
|
<Suspense fallback={<div className="w-full h-24 rounded-lg bg-muted animate-pulse" />}>
|
||||||
|
<WaveformPlayer src={mediaSrc} className="w-full" />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-24 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<Music className="h-8 w-8 text-muted-foreground animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<AuthImage
|
||||||
|
src={getFileThumbnailUrl(fileId)}
|
||||||
|
alt={name}
|
||||||
|
className="w-full rounded-lg object-contain max-h-48 bg-muted"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||||
|
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{mimeType.split("/").pop()?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toolName(toolId: string): string {
|
function toolName(toolId: string): string {
|
||||||
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||||
}
|
}
|
||||||
@@ -184,13 +260,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
|||||||
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
|
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Preview */}
|
||||||
<div className={cn("border-b border-border", mobile ? "" : "pb-4")}>
|
<div className={cn("border-b border-border", mobile ? "" : "pb-4")}>
|
||||||
<AuthImage
|
<FilePreview fileId={details.id} mimeType={details.mimeType} name={details.originalName} />
|
||||||
src={getFileThumbnailUrl(details.id)}
|
|
||||||
alt={details.originalName}
|
|
||||||
className="w-full rounded-lg object-contain max-h-48 bg-muted"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details card */}
|
{/* Details card */}
|
||||||
|
|||||||
Reference in New Issue
Block a user