Files
SnapOtter/apps/web/src/components/files/file-details.tsx
T
SnapOtter 0309e0f680 chore: deploy to Cloudflare Pages and update branding
- Add Cloudflare Pages deployment for landing page (snapotter.com) and
  docs (docs.snapotter.com)
- Create deploy-landing.yml and update deploy-docs.yml workflows
- Update CI to ignore apps/landing/** paths
- Fix logo transparency (remove white background) across all apps
- Recreate social-preview.png with SnapOtter branding
- Update all docs URLs from GitHub Pages to docs.snapotter.com
- Update VitePress config: light theme default, fix llms.txt paths
- Add .vitepress/cache/ and .env.* to gitignore
2026-04-24 18:06:29 +08:00

202 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TOOLS } from "@snapotter/shared";
import { FileImage, Workflow } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
apiGetFileDetails,
formatHeaders,
getFileDownloadUrl,
getFileThumbnailUrl,
type UserFileDetail,
} from "@/lib/api";
import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import { useFilesPageStore } from "@/stores/files-page-store";
function toolName(toolId: string): string {
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface FileDetailsProps {
mobile?: boolean;
}
export function FileDetails({ mobile = false }: FileDetailsProps) {
const { selectedFileId } = useFilesPageStore();
const setFiles = useFileStore((s) => s.setFiles);
const navigate = useNavigate();
const [details, setDetails] = useState<UserFileDetail | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
useEffect(() => {
if (!selectedFileId) {
setDetails(null);
return;
}
setLoadingDetails(true);
apiGetFileDetails(selectedFileId)
.then(setDetails)
.catch(() => setDetails(null))
.finally(() => setLoadingDetails(false));
}, [selectedFileId]);
async function handleOpenFile() {
if (!details) return;
const { checkedIds, files: allFiles } = useFilesPageStore.getState();
// If multiple files are checked, open all of them; otherwise just the selected one
const filesToOpen =
checkedIds.size > 1
? allFiles.filter((f) => checkedIds.has(f.id))
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
const downloaded = await Promise.all(
filesToOpen.map(async (f) => {
const res = await fetch(getFileDownloadUrl(f.id), {
headers: formatHeaders(),
});
if (!res.ok) return null;
const blob = await res.blob();
return { file: new File([blob], f.originalName, { type: f.mimeType }), serverId: f.id };
}),
);
const valid = downloaded.filter((d): d is NonNullable<typeof d> => d !== null);
if (valid.length === 0) return;
setFiles(valid.map((d) => d.file));
navigate("/");
// Set serverFileId on each entry so tool processing creates new versions
setTimeout(() => {
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
if (store.entries[i]) {
store.updateEntry(i, { serverFileId: valid[i].serverId });
}
}
}, 0);
}
if (!selectedFileId) {
return (
<div
className={cn(
"flex flex-col items-center justify-center text-muted-foreground",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
<FileImage className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">Select a file to view details</p>
</div>
);
}
if (loadingDetails) {
return (
<div
className={cn(
"flex items-center justify-center",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!details) return null;
return (
<div
className={cn(
"overflow-y-auto",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
{/* Thumbnail */}
<div className={cn("border-b border-border", mobile ? "" : "pb-4")}>
<img
src={getFileThumbnailUrl(details.id)}
alt={details.originalName}
className="w-full rounded-lg object-contain max-h-48 bg-muted"
/>
</div>
{/* Details card */}
<div className="flex-1">
<div className="rounded-lg border border-border overflow-hidden">
<div className="bg-blue-500/10 border-b border-border px-3 py-2">
<h4 className="text-sm font-semibold text-blue-600 dark:text-blue-400">File Details</h4>
</div>
<div className="divide-y divide-border">
<DetailRow label="Name" value={details.originalName} />
<DetailRow
label="Format"
value={details.mimeType.replace("image/", "").toUpperCase()}
/>
<DetailRow label="Size" value={formatSize(details.size)} />
<DetailRow
label="Dimensions"
value={details.width && details.height ? `${details.width} × ${details.height}` : "—"}
/>
<DetailRow label="Version" value={`V${details.version}`} />
<DetailRow
label="Tools Used"
value={
details.toolChain.length > 0 ? details.toolChain.map(toolName).join(", ") : "None"
}
/>
</div>
</div>
</div>
{/* Action buttons */}
<div className={cn("border-t border-border flex flex-col gap-2", mobile ? "pt-3" : "pt-4")}>
<button
type="button"
onClick={handleOpenFile}
className="w-full px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
>
Open File
</button>
<button
type="button"
onClick={() => {
const { checkedIds } = useFilesPageStore.getState();
const ids = checkedIds.size > 1 ? Array.from(checkedIds) : [details.id];
navigate("/automate", { state: { libraryFileIds: ids } });
}}
className="w-full px-4 py-2 border border-primary text-primary text-sm font-medium rounded-lg hover:bg-primary/5 transition-colors flex items-center justify-center gap-2"
>
<Workflow className="h-4 w-4" />
Open in Pipeline
</button>
</div>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between items-start gap-2 px-3 py-2">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right break-all">{value}</span>
</div>
);
}