mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: implement Files page with persistent storage and version tracking
Three-panel file manager (nav, list, details) modeled after Stirling-PDF. - Backend: user_files table, /api/v1/files/* CRUD routes, file storage helpers, thumbnail generation via Sharp, recursive CTE version chains - Frontend: FilesNav, FileList, FileDetails, FileUploadArea components, Zustand store, mobile layout with bottom sheet - Integration: tool-factory auto-saves results as new versions when fileId is provided, "Open File" loads file into tool processing flow - Search, bulk select/delete/download, version badges, tool chain tags
This commit is contained in:
+12
-12
@@ -1,13 +1,13 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { HomePage } from "./pages/home-page";
|
||||
import { LoginPage } from "./pages/login-page";
|
||||
import { ToolPage } from "./pages/tool-page";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { AutomatePage } from "./pages/automate-page";
|
||||
import { FilesPage } from "./pages/files-page";
|
||||
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { HomePage } from "./pages/home-page";
|
||||
import { LoginPage } from "./pages/login-page";
|
||||
import { ToolPage } from "./pages/tool-page";
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
@@ -86,12 +86,12 @@ export function App() {
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</AuthGuard>
|
||||
</KeyboardShortcutProvider>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { FileImage } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileImage } from "lucide-react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import {
|
||||
apiGetFileDetails,
|
||||
getFileThumbnailUrl,
|
||||
getFileDownloadUrl,
|
||||
getFileThumbnailUrl,
|
||||
type UserFileDetail,
|
||||
} from "@/lib/api";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
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;
|
||||
@@ -124,23 +124,20 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<DetailRow label="Name" value={details.originalName} />
|
||||
<DetailRow label="Format" value={details.mimeType.replace("image/", "").toUpperCase()} />
|
||||
<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}`
|
||||
: "—"
|
||||
}
|
||||
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"
|
||||
details.toolChain.length > 0 ? details.toolChain.map(toolName).join(", ") : "None"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -150,6 +147,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
{/* Open File button */}
|
||||
<div className={cn("border-t border-border", 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"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -32,7 +32,13 @@ export function FileListItem({ file }: FileListItemProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => selectFile(file.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") selectFile(file.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors",
|
||||
isSelected
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Download, Search, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Search, Trash2, Download } from "lucide-react";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
import { getFileDownloadUrl } from "@/lib/api";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
import { FileListItem } from "./file-list-item";
|
||||
|
||||
export function FileList() {
|
||||
@@ -77,6 +77,7 @@ export function FileList() {
|
||||
{someChecked && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteChecked}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
>
|
||||
@@ -84,6 +85,7 @@ export function FileList() {
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkDownload}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||
>
|
||||
@@ -111,9 +113,7 @@ export function FileList() {
|
||||
<p className="text-sm text-muted-foreground">No files found</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && files.map((file) => (
|
||||
<FileListItem key={file.id} file={file} />
|
||||
))}
|
||||
{!loading && !error && files.map((file) => <FileListItem key={file.id} file={file} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
|
||||
export function FileUploadArea() {
|
||||
const { uploadFiles, loading } = useFilesPageStore();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
@@ -20,9 +19,7 @@ export function FileUploadArea() {
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length > 0) uploadFiles(files);
|
||||
}
|
||||
|
||||
@@ -35,11 +32,10 @@ export function FileUploadArea() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !loading && inputRef.current?.click()}
|
||||
className={cn(
|
||||
"w-full max-w-lg flex flex-col items-center justify-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors cursor-pointer",
|
||||
dragging
|
||||
@@ -57,19 +53,16 @@ export function FileUploadArea() {
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{loading ? "Uploading..." : "Drop images here"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
or click to select files
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">or click to select files</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clock, Upload, Cloud } from "lucide-react";
|
||||
import { Clock, Cloud, Upload } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
|
||||
@@ -16,6 +16,7 @@ export function FilesNav() {
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Workflow,
|
||||
FolderOpen,
|
||||
Settings as SettingsIcon,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ToolPanel } from "./tool-panel";
|
||||
import { Footer } from "./footer";
|
||||
import { Dropzone } from "../common/dropzone";
|
||||
import { SettingsDialog } from "../settings/settings-dialog";
|
||||
import { HelpDialog } from "../help/help-dialog";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dropzone } from "../common/dropzone";
|
||||
import { HelpDialog } from "../help/help-dialog";
|
||||
import { SettingsDialog } from "../settings/settings-dialog";
|
||||
import { Footer } from "./footer";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ToolPanel } from "./tool-panel";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -33,7 +26,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
{!isMobile && (
|
||||
<Sidebar onSettingsClick={() => setSettingsOpen(true)} onHelpClick={() => setHelpOpen(true)} />
|
||||
<Sidebar
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onHelpClick={() => setHelpOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
@@ -89,12 +85,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
|
||||
{showToolPanel && !isMobile && <ToolPanel />}
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"flex-1 flex flex-col overflow-hidden",
|
||||
isMobile && "pt-12 pb-16"
|
||||
)}
|
||||
>
|
||||
<main className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-16")}>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
|
||||
{children || <Dropzone onFiles={onFiles} accept="image/*" />}
|
||||
</div>
|
||||
@@ -124,16 +115,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
{/* Help dialog */}
|
||||
<HelpDialog
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
/>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Workflow,
|
||||
HelpCircle,
|
||||
Settings,
|
||||
Grid3x3,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface SidebarItem {
|
||||
icon: LucideIcon;
|
||||
@@ -45,7 +38,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
||||
"flex items-center gap-3 px-4 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
@@ -57,7 +50,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
||||
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
@@ -89,9 +82,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className="flex flex-col p-3 gap-1">
|
||||
{topItems.map((item) =>
|
||||
renderItem(item, location.pathname === item.href)
|
||||
)}
|
||||
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||
<div className="border-t border-border my-2" />
|
||||
{bottomItems.map((item) => renderItem(item, false))}
|
||||
</div>
|
||||
@@ -101,9 +92,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
||||
return (
|
||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{topItems.map((item) =>
|
||||
renderItem(item, location.pathname === item.href)
|
||||
)}
|
||||
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||
</div>
|
||||
<div className="border-t border-border w-10 my-2" />
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
@@ -29,7 +29,11 @@ const IDLE_PROGRESS: ToolProgress = {
|
||||
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
||||
const AI_PYTHON_TOOLS = new Set([
|
||||
"remove-background", "upscale", "blur-faces", "erase-object", "ocr",
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
]);
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
@@ -89,9 +93,7 @@ export function useToolProcessor(toolId: string) {
|
||||
// For AI tools, open SSE before uploading
|
||||
if (isAiTool) {
|
||||
try {
|
||||
const es = new EventSource(
|
||||
`/api/v1/jobs/${clientJobId}/progress`,
|
||||
);
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
@@ -251,7 +253,8 @@ export function useToolProcessor(toolId: string) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "batch") {
|
||||
const pct = data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||
const pct =
|
||||
data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "processing",
|
||||
@@ -261,10 +264,17 @@ export function useToolProcessor(toolId: string) {
|
||||
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore malformed SSE */ }
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
es.onerror = () => { es.close(); eventSourceRef.current = null; };
|
||||
} catch { /* SSE failed, proceed without */ }
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
} catch {
|
||||
/* SSE failed, proceed without */
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("file", file);
|
||||
@@ -280,7 +290,10 @@ export function useToolProcessor(toolId: string) {
|
||||
});
|
||||
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
@@ -288,7 +301,9 @@ export function useToolProcessor(toolId: string) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`;
|
||||
} catch { errorMsg = `Batch processing failed: ${response.status}`; }
|
||||
} catch {
|
||||
errorMsg = `Batch processing failed: ${response.status}`;
|
||||
}
|
||||
setError(errorMsg);
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
@@ -300,10 +315,12 @@ export function useToolProcessor(toolId: string) {
|
||||
|
||||
// Extract files from ZIP using fflate
|
||||
const { unzipSync } = await import("fflate");
|
||||
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
|
||||
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
|
||||
const extracted = unzipSync(zipBuffer);
|
||||
|
||||
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(decodeURIComponent);
|
||||
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(
|
||||
decodeURIComponent,
|
||||
);
|
||||
const entries = useFileStore.getState().entries;
|
||||
const extractedNames = Object.keys(extracted);
|
||||
|
||||
@@ -316,7 +333,11 @@ export function useToolProcessor(toolId: string) {
|
||||
}
|
||||
if (zipName && extracted[zipName]) {
|
||||
const blob = new Blob([extracted[zipName] as BlobPart]);
|
||||
updateEntry(i, { processedUrl: URL.createObjectURL(blob), processedSize: blob.size, status: "completed" });
|
||||
updateEntry(i, {
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedSize: blob.size,
|
||||
status: "completed",
|
||||
});
|
||||
} else {
|
||||
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
||||
}
|
||||
@@ -326,7 +347,10 @@ export function useToolProcessor(toolId: string) {
|
||||
setProgress(IDLE_PROGRESS);
|
||||
} catch (err) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Batch processing failed");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
|
||||
+8
-10
@@ -59,14 +59,12 @@ export function clearToken() {
|
||||
|
||||
// ── File Upload / Download ──────────────────────────────────────
|
||||
|
||||
export async function apiUpload(
|
||||
files: File[],
|
||||
): Promise<{
|
||||
export async function apiUpload(files: File[]): Promise<{
|
||||
jobId: string;
|
||||
files: Array<{ name: string; size: number; format: string }>;
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append("files", f));
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
@@ -118,14 +116,17 @@ export async function apiListFiles(params?: {
|
||||
}
|
||||
|
||||
export async function apiGetFileDetails(id: string): Promise<UserFileDetail> {
|
||||
return apiGet(`/v1/files/${id}`);
|
||||
const res = await apiGet<{ file: UserFile; versions: UserFileDetail["versions"] }>(
|
||||
`/v1/files/${id}`,
|
||||
);
|
||||
return { ...res.file, versions: res.versions };
|
||||
}
|
||||
|
||||
export async function apiUploadUserFiles(
|
||||
files: File[],
|
||||
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append("files", f));
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/files/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
@@ -156,10 +157,7 @@ export function getFileDownloadUrl(id: string): string {
|
||||
return `/api/v1/files/${id}/download`;
|
||||
}
|
||||
|
||||
export async function apiDownloadBlob(
|
||||
jobId: string,
|
||||
filename: string,
|
||||
): Promise<Blob> {
|
||||
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { FilesNav } from "@/components/files/files-nav";
|
||||
import { FileList } from "@/components/files/file-list";
|
||||
import { useState } from "react";
|
||||
import { FileDetails } from "@/components/files/file-details";
|
||||
import { FileList } from "@/components/files/file-list";
|
||||
import { FileUploadArea } from "@/components/files/file-upload-area";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
import { FilesNav } from "@/components/files/files-nav";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||
|
||||
export function FilesPage() {
|
||||
const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore();
|
||||
@@ -20,6 +20,7 @@ export function FilesPage() {
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("recent")}
|
||||
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||
activeTab === "recent"
|
||||
@@ -30,6 +31,7 @@ export function FilesPage() {
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("upload")}
|
||||
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||
activeTab === "upload"
|
||||
@@ -42,7 +44,17 @@ export function FilesPage() {
|
||||
</div>
|
||||
|
||||
{activeTab === "recent" ? (
|
||||
<div className="flex-1 overflow-hidden" onClick={() => { if (selectedFileId) setShowDetails(true); }}>
|
||||
<div
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
className="flex-1 overflow-hidden"
|
||||
onClick={() => {
|
||||
if (selectedFileId) setShowDetails(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && selectedFileId) setShowDetails(true);
|
||||
}}
|
||||
>
|
||||
<FileList />
|
||||
</div>
|
||||
) : (
|
||||
@@ -51,14 +63,22 @@ export function FilesPage() {
|
||||
|
||||
{/* Mobile detail bottom sheet */}
|
||||
{showDetails && selectedFileId && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowDetails(false)}>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="File Details"
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowDetails(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowDetails(false);
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-semibold">File Details</span>
|
||||
<button onClick={() => setShowDetails(false)}>
|
||||
<button type="button" onClick={() => setShowDetails(false)}>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -87,8 +87,7 @@ function deriveCompat(entries: FileEntry[], selectedIndex: number) {
|
||||
files: entries.map((e) => e.file),
|
||||
currentEntry: entry,
|
||||
hasFiles: entries.length > 0,
|
||||
allProcessed:
|
||||
entries.length > 0 && entries.every((e) => e.status === "completed"),
|
||||
allProcessed: entries.length > 0 && entries.every((e) => e.status === "completed"),
|
||||
selectedFileName: entry ? entry.file.name : null,
|
||||
selectedFileSize: entry ? entry.file.size : null,
|
||||
originalBlobUrl: entry ? entry.blobUrl : null,
|
||||
@@ -183,8 +182,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
set({ entries, ...deriveCompat(entries, idx) });
|
||||
},
|
||||
|
||||
setBatchZip: (blob, filename) =>
|
||||
set({ batchZipBlob: blob, batchZipFilename: filename }),
|
||||
setBatchZip: (blob, filename) => set({ batchZipBlob: blob, batchZipFilename: filename }),
|
||||
|
||||
setProcessing: (v) => set({ processing: v }),
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
apiListFiles,
|
||||
apiUploadUserFiles,
|
||||
apiDeleteUserFiles,
|
||||
type UserFile,
|
||||
} from "@/lib/api";
|
||||
import { apiDeleteUserFiles, apiListFiles, apiUploadUserFiles, type UserFile } from "@/lib/api";
|
||||
|
||||
interface FilesPageState {
|
||||
files: UserFile[];
|
||||
@@ -75,12 +70,15 @@ export const useFilesPageStore = create<FilesPageState>((set, get) => ({
|
||||
toggleChecked: (id) => {
|
||||
const { checkedIds } = get();
|
||||
const next = new Set(checkedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
set({ checkedIds: next });
|
||||
},
|
||||
toggleCheckAll: () => {
|
||||
const { files, checkedIds } = get();
|
||||
set({ checkedIds: checkedIds.size === files.length ? new Set() : new Set(files.map(f => f.id)) });
|
||||
set({
|
||||
checkedIds: checkedIds.size === files.length ? new Set() : new Set(files.map((f) => f.id)),
|
||||
});
|
||||
},
|
||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
Reference in New Issue
Block a user