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:
Siddharth Kumar Sah
2026-03-26 01:10:51 +08:00
parent 926b52d330
commit 62fbb5484c
24 changed files with 771 additions and 470 deletions
+12 -14
View File
@@ -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
+5 -5
View File
@@ -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>
);
}
+2 -1
View File
@@ -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",
+14 -29
View File
@@ -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>
);
}
+6 -17
View File
@@ -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">