mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user