feat(files): add Files page with nav, list, details, upload, and routing

Creates the full Files page UI (Tasks 8 & 9): FilesNav, FileListItem,
FileList, FileDetails, FileUploadArea components, FilesPage layout, and
wires up the /files route plus sidebar/mobile-nav entries.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:10:50 +08:00
parent 24644c1c10
commit 788b8c8bad
8 changed files with 506 additions and 1 deletions
+2
View File
@@ -4,6 +4,7 @@ import { HomePage } from "./pages/home-page";
import { LoginPage } from "./pages/login-page";
import { ToolPage } from "./pages/tool-page";
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";
@@ -87,6 +88,7 @@ export function App() {
<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 />} />
@@ -0,0 +1,157 @@
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,
type UserFileDetail,
} from "@/lib/api";
import { useFilesPageStore } from "@/stores/files-page-store";
import { useFileStore } from "@/stores/file-store";
import { cn } from "@/lib/utils";
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 res = await fetch(getFileDownloadUrl(details.id), {
headers: { Authorization: `Bearer ${localStorage.getItem("stirling-token") || ""}` },
});
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], details.originalName, { type: details.mimeType });
setFiles([file]);
navigate("/");
}
if (!selectedFileId) {
return (
<div
className={cn(
"flex flex-col items-center justify-center text-muted-foreground",
mobile ? "flex-1" : "w-72 shrink-0 border-l border-border",
)}
>
<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-1" : "w-72 shrink-0 border-l border-border",
)}
>
<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(
"flex flex-col overflow-y-auto",
mobile ? "flex-1" : "w-72 shrink-0 border-l border-border",
)}
>
{/* Thumbnail */}
<div className="p-4 border-b border-border">
<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 p-4">
<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>
{/* Open File button */}
<div className="p-4 border-t border-border">
<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>
</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>
);
}
@@ -0,0 +1,87 @@
import { TOOLS } from "@stirling-image/shared";
import { cn } from "@/lib/utils";
import type { UserFile } from "@/lib/api";
import { useFilesPageStore } from "@/stores/files-page-store";
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`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function toolName(toolId: string): string {
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
}
interface FileListItemProps {
file: UserFile;
}
export function FileListItem({ file }: FileListItemProps) {
const { selectedFileId, checkedIds, selectFile, toggleChecked } = useFilesPageStore();
const isSelected = selectedFileId === file.id;
const isChecked = checkedIds.has(file.id);
return (
<div
onClick={() => selectFile(file.id)}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted border border-transparent",
)}
>
{/* Checkbox */}
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleChecked(file.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0 accent-primary"
/>
{/* File name */}
<span className="flex-1 min-w-0 text-sm font-medium text-foreground truncate">
{file.originalName}
</span>
{/* Tool chain */}
{file.toolChain.length > 0 && (
<span className="hidden md:block text-xs text-primary shrink-0">
{file.toolChain.map(toolName).join(" → ")}
</span>
)}
{/* Version badge */}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium shrink-0",
file.version >= 2
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
V{file.version}
</span>
{/* Size */}
<span className="hidden sm:block text-xs text-muted-foreground shrink-0 w-16 text-right">
{formatSize(file.size)}
</span>
{/* Date */}
<span className="hidden lg:block text-xs text-muted-foreground shrink-0 w-24 text-right">
{formatDate(file.createdAt)}
</span>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
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 { FileListItem } from "./file-list-item";
export function FileList() {
const {
files,
checkedIds,
loading,
error,
fetchFiles,
deleteChecked,
toggleCheckAll,
setSearchQuery,
} = useFilesPageStore();
const [inputValue, setInputValue] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setInputValue(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setSearchQuery(val);
fetchFiles();
}, 300);
}
function handleBulkDownload() {
for (const id of checkedIds) {
const a = document.createElement("a");
a.href = getFileDownloadUrl(id);
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
const allChecked = files.length > 0 && checkedIds.size === files.length;
const someChecked = checkedIds.size > 0;
return (
<div className="flex-1 flex flex-col overflow-hidden border-r border-border">
{/* Search */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search files..."
value={inputValue}
onChange={handleSearchChange}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<input
type="checkbox"
checked={allChecked}
onChange={toggleCheckAll}
className="h-4 w-4 accent-primary"
/>
<span className="text-xs text-muted-foreground flex-1">
{someChecked ? `${checkedIds.size} selected` : `${files.length} files`}
</span>
{someChecked && (
<>
<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"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={handleBulkDownload}
className="flex items-center gap-1 px-2 py-1 text-xs text-foreground hover:bg-muted rounded-lg transition-colors"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</>
)}
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && error && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!loading && !error && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">No files found</p>
</div>
)}
{!loading && !error && files.map((file) => (
<FileListItem key={file.id} file={file} />
))}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import { useRef, useState } from "react";
import { Upload } from "lucide-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();
setDragging(true);
}
function handleDragLeave() {
setDragging(false);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
);
if (files.length > 0) uploadFiles(files);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) uploadFiles(files);
// Reset input so the same file can be re-selected
e.target.value = "";
}
return (
<div className="flex-1 flex items-center justify-center p-8">
<div
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
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50",
loading && "pointer-events-none opacity-50",
)}
>
{loading ? (
<div className="h-10 w-10 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="h-10 w-10 text-muted-foreground" />
)}
<div className="text-center">
<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>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleInputChange}
/>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { Clock, Upload, Cloud } from "lucide-react";
import { cn } from "@/lib/utils";
import { useFilesPageStore } from "@/stores/files-page-store";
export function FilesNav() {
const { activeTab, setActiveTab } = useFilesPageStore();
const items = [
{ id: "recent" as const, label: "Recent", icon: Clock },
{ id: "upload" as const, label: "Upload Files", icon: Upload },
];
return (
<div className="w-48 border-r border-border p-4 shrink-0 hidden md:block">
<h3 className="text-sm font-semibold text-foreground mb-3">My Files</h3>
<div className="space-y-1">
{items.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
activeTab === item.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
))}
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground/40 cursor-not-allowed">
<Cloud className="h-4 w-4" />
Google Drive
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded ml-auto">Soon</span>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -3,10 +3,10 @@ import { cn } from "@/lib/utils";
import {
LayoutGrid,
Workflow,
FolderOpen,
HelpCircle,
Settings,
Grid3x3,
FolderOpen,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
+25
View File
@@ -0,0 +1,25 @@
import { AppLayout } from "@/components/layout/app-layout";
import { FilesNav } from "@/components/files/files-nav";
import { FileList } from "@/components/files/file-list";
import { FileDetails } from "@/components/files/file-details";
import { FileUploadArea } from "@/components/files/file-upload-area";
import { useFilesPageStore } from "@/stores/files-page-store";
export function FilesPage() {
const { activeTab } = useFilesPageStore();
return (
<AppLayout showToolPanel={false}>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
{activeTab === "recent" ? (
<>
<FileList />
<FileDetails />
</>
) : (
<FileUploadArea />
)}
</div>
</AppLayout>
);
}