mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Phase 1 quick fixes: - Add isInputFocused() guard to Cmd+A/D/T/J shortcuts (P1-7) - Add Go Home button to tool-not-found page (P2-30) - Fix hardcoded "Import from Library" string in file library modal (P2-28) - Fix TeamEntry.id type from number to string to match API (P2-6) - Add eye toggle to confirm password field (P2-10) - Add Apply/Cancel buttons to Free Transform options bar (P2-14) Phase 2 state fixes: - Add sessionStorage persistence to pipeline store (P1-26) - Fix Free Transform 0 dimensions by falling back to selection bounds (P1-6) Phase 3 editor features: - Constrain brush/eraser drawing within active selection bounds (P1-5) - Add feather radius control to selection options (P2-21) - Add flow control slider to brush options (P2-22) - Add brush/block mode selector to eraser options (P2-23) - Add estimated file size display to export dialog (P2-18) Phase 4: - Add Playwright e2e tests for key fixes (404 page, routing, pipeline persistence, export dialog)
270 lines
9.2 KiB
TypeScript
270 lines
9.2 KiB
TypeScript
import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "@/contexts/i18n-context";
|
|
import {
|
|
apiListFiles,
|
|
formatHeaders,
|
|
getFileDownloadUrl,
|
|
getFileThumbnailUrl,
|
|
type UserFile,
|
|
} from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let revoked = false;
|
|
fetch(src, { headers: formatHeaders() })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error();
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
if (revoked) return;
|
|
setBlobUrl(URL.createObjectURL(blob));
|
|
})
|
|
.catch(() => {
|
|
if (!revoked) setFailed(true);
|
|
});
|
|
return () => {
|
|
revoked = true;
|
|
setBlobUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return null;
|
|
});
|
|
};
|
|
}, [src]);
|
|
|
|
if (failed) {
|
|
return (
|
|
<div className={cn("flex items-center justify-center bg-muted/50", className)}>
|
|
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!blobUrl) {
|
|
return (
|
|
<div className={cn("flex items-center justify-center bg-muted/30", className)}>
|
|
<div className="h-4 w-4 border-2 border-muted-foreground/30 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <img src={blobUrl} alt={alt} className={className} loading="lazy" />;
|
|
}
|
|
|
|
interface FileLibraryModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onImport: (files: File[]) => void;
|
|
}
|
|
|
|
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
|
|
const { t } = useTranslation();
|
|
const [files, setFiles] = useState<UserFile[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const fetchFiles = useCallback(async (search?: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await apiListFiles({ search: search || undefined, limit: 200 });
|
|
setFiles(result.files);
|
|
} catch {
|
|
setFiles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
fetchFiles();
|
|
setCheckedIds(new Set());
|
|
setSearchQuery("");
|
|
}
|
|
}, [open, fetchFiles]);
|
|
|
|
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const val = e.target.value;
|
|
setSearchQuery(val);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => fetchFiles(val), 300);
|
|
}
|
|
|
|
function toggleCheck(id: string) {
|
|
setCheckedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleAll() {
|
|
if (checkedIds.size === files.length) {
|
|
setCheckedIds(new Set());
|
|
} else {
|
|
setCheckedIds(new Set(files.map((f) => f.id)));
|
|
}
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (checkedIds.size === 0) return;
|
|
setImporting(true);
|
|
try {
|
|
const toDownload = files.filter((f) => checkedIds.has(f.id));
|
|
const downloaded = await Promise.all(
|
|
toDownload.map(async (f) => {
|
|
const res = await fetch(getFileDownloadUrl(f.id), { headers: formatHeaders() });
|
|
if (!res.ok) return null;
|
|
const blob = await res.blob();
|
|
return new File([blob], f.originalName, { type: f.mimeType });
|
|
}),
|
|
);
|
|
const valid = downloaded.filter((f): f is File => f !== null);
|
|
if (valid.length > 0) {
|
|
onImport(valid);
|
|
onClose();
|
|
}
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
const allChecked = files.length > 0 && checkedIds.size === files.length;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
aria-hidden="true"
|
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
|
|
onClick={onClose}
|
|
/>
|
|
<div className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
|
<FolderOpen className="h-5 w-5 text-primary" />
|
|
<h2 className="text-sm font-semibold text-foreground flex-1">{t.automate.importFromLibrary}</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="px-4 py-2 border-b border-border shrink-0">
|
|
<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={searchQuery}
|
|
onChange={handleSearchChange}
|
|
className="w-full ps-8 pe-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>
|
|
|
|
{/* Select all toolbar */}
|
|
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={allChecked}
|
|
onChange={toggleAll}
|
|
className="h-4 w-4 accent-primary"
|
|
/>
|
|
<span className="text-xs text-muted-foreground flex-1">
|
|
{checkedIds.size > 0 ? `${checkedIds.size} selected` : `${files.length} files`}
|
|
</span>
|
|
</div>
|
|
|
|
{/* File grid */}
|
|
<div className="flex-1 overflow-y-auto p-3 min-h-0">
|
|
{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 && 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 && files.length > 0 && (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
|
{files.map((file) => {
|
|
const checked = checkedIds.has(file.id);
|
|
return (
|
|
<button
|
|
key={file.id}
|
|
type="button"
|
|
onClick={() => toggleCheck(file.id)}
|
|
className={cn(
|
|
"relative group rounded-lg border overflow-hidden aspect-square flex items-center justify-center bg-muted/30 transition-all",
|
|
checked
|
|
? "border-primary ring-2 ring-primary/30"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<AuthImage
|
|
src={getFileThumbnailUrl(file.id)}
|
|
alt={file.originalName}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
{checked && (
|
|
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
|
|
<Check className="h-3 w-3" />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
|
|
<p className="text-[10px] text-white truncate">{file.originalName}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleImport}
|
|
disabled={checkedIds.size === 0 || importing}
|
|
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{importing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Importing...
|
|
</>
|
|
) : (
|
|
<>Import{checkedIds.size > 0 ? ` (${checkedIds.size})` : ""}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|