mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- 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
202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
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>
|
||
);
|
||
}
|