Files
mylegal-stage-law/src/routes/files.index.tsx
T
2026-04-18 01:26:18 +00:00

598 lines
22 KiB
TypeScript

import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { supabase } from "@/integrations/supabase/client";
import {
Download,
FileText,
Search,
Folder,
Filter,
X,
Eye,
ExternalLink,
Loader2,
} from "lucide-react";
import { formatDate } from "@/lib/format";
import { toast } from "sonner";
export const Route = createFileRoute("/files/")({
component: () => (
<ProtectedLayout>
<FilesPage />
</ProtectedLayout>
),
});
// File-type buckets
const TYPE_BUCKETS = [
{ key: "pdf", label: "PDF", test: (n: string, m: string) => m === "application/pdf" || /\.pdf$/i.test(n) },
{ key: "image", label: "Image", test: (n: string, m: string) => m.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(n) },
{ key: "doc", label: "Word", test: (n: string, m: string) => /word|officedocument\.wordprocessing/i.test(m) || /\.(docx?|rtf)$/i.test(n) },
{ key: "sheet", label: "Spreadsheet", test: (n: string, m: string) => /sheet|excel|csv/i.test(m) || /\.(xlsx?|csv|ods)$/i.test(n) },
{ key: "slide", label: "Presentation", test: (n: string, m: string) => /presentation|powerpoint/i.test(m) || /\.(pptx?|odp)$/i.test(n) },
{ key: "text", label: "Text", test: (n: string, m: string) => m.startsWith("text/") || /\.(txt|md|json|xml|log|yml|yaml)$/i.test(n) },
{ key: "audio", label: "Audio", test: (n: string, m: string) => m.startsWith("audio/") || /\.(mp3|wav|m4a|ogg)$/i.test(n) },
{ key: "video", label: "Video", test: (n: string, m: string) => m.startsWith("video/") || /\.(mp4|webm|mov|avi)$/i.test(n) },
{ key: "archive", label: "Archive", test: (n: string, m: string) => /zip|tar|rar|7z/i.test(m) || /\.(zip|tar|gz|rar|7z)$/i.test(n) },
] as const;
const DATE_RANGES = [
{ key: "any", label: "Any time", days: null as number | null },
{ key: "7", label: "Last 7 days", days: 7 },
{ key: "30", label: "Last 30 days", days: 30 },
{ key: "90", label: "Last 90 days", days: 90 },
{ key: "365", label: "Last year", days: 365 },
] as const;
function bucketOf(name: string, mime: string) {
const n = (name || "").toLowerCase();
const m = (mime || "").toLowerCase();
return TYPE_BUCKETS.find((b) => b.test(n, m))?.key ?? "other";
}
function FilesPage() {
const [docs, setDocs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
// Multi-select filters
const [caseFilter, setCaseFilter] = useState<Set<string>>(new Set());
const [uploaderFilter, setUploaderFilter] = useState<Set<string>>(new Set());
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set());
const [folderFilter, setFolderFilter] = useState<Set<string>>(new Set());
const [dateRange, setDateRange] = useState<string>("any");
// Preview state
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const load = async () => {
setLoading(true);
const { data, error } = await supabase
.from("documents")
.select("*")
.order("created_at", { ascending: false })
.limit(1000);
if (error) {
toast.error("Could not load files", { description: error.message });
setLoading(false);
return;
}
const caseIds = Array.from(new Set((data ?? []).map((d: any) => d.case_id).filter(Boolean)));
const uploaderIds = Array.from(new Set((data ?? []).map((d: any) => d.uploaded_by).filter(Boolean)));
const [{ data: cases }, { data: profs }] = await Promise.all([
caseIds.length
? supabase
.from("cases")
.select("id, case_number, title, client:clients(id, name)")
.in("id", caseIds)
: Promise.resolve({ data: [] as any[] }),
uploaderIds.length
? supabase.from("profiles").select("id, full_name, email").in("id", uploaderIds)
: Promise.resolve({ data: [] as any[] }),
]);
const caseMap = Object.fromEntries((cases ?? []).map((c: any) => [c.id, c]));
const profMap = Object.fromEntries((profs ?? []).map((p: any) => [p.id, p]));
setDocs(
(data ?? []).map((d: any) => ({
...d,
case: caseMap[d.case_id],
uploader: d.uploaded_by ? profMap[d.uploaded_by] : null,
_bucket: bucketOf(d.name, d.mime_type),
})),
);
setLoading(false);
};
useEffect(() => {
load();
}, []);
// Build filter option lists from current docs
const caseOptions = useMemo(() => {
const m = new Map<string, { id: string; label: string }>();
docs.forEach((d) => {
if (d.case) m.set(d.case.id, { id: d.case.id, label: `${d.case.case_number} — ${d.case.title}` });
});
return Array.from(m.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [docs]);
const uploaderOptions = useMemo(() => {
const m = new Map<string, { id: string; label: string }>();
docs.forEach((d) => {
if (d.uploader) m.set(d.uploader.id, { id: d.uploader.id, label: d.uploader.full_name || d.uploader.email });
});
return Array.from(m.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [docs]);
const folderOptions = useMemo(() => {
const set = new Set<string>();
docs.forEach((d) => set.add(d.folder || ""));
return Array.from(set).sort();
}, [docs]);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const days = DATE_RANGES.find((r) => r.key === dateRange)?.days ?? null;
const cutoff = days ? Date.now() - days * 86400_000 : null;
return docs.filter((d) => {
if (s) {
const hay = [d.name, d.folder, d.case?.case_number, d.case?.title, d.case?.client?.name, d.uploader?.full_name, d.uploader?.email]
.filter(Boolean)
.map((v: string) => String(v).toLowerCase());
if (!hay.some((v) => v.includes(s))) return false;
}
if (caseFilter.size && (!d.case || !caseFilter.has(d.case.id))) return false;
if (uploaderFilter.size && (!d.uploader || !uploaderFilter.has(d.uploader.id))) return false;
if (typeFilter.size && !typeFilter.has(d._bucket)) return false;
if (folderFilter.size && !folderFilter.has(d.folder || "")) return false;
if (cutoff && new Date(d.created_at).getTime() < cutoff) return false;
return true;
});
}, [docs, q, caseFilter, uploaderFilter, typeFilter, folderFilter, dateRange]);
const download = async (doc: any) => {
const { data, error } = await supabase.storage.from("case-documents").createSignedUrl(doc.storage_path, 60);
if (error) {
toast.error(error.message);
return;
}
window.open(data.signedUrl, "_blank");
};
const openPreview = async (doc: any) => {
setPreviewDoc(doc);
setPreviewUrl(null);
setPreviewLoading(true);
const { data, error } = await supabase.storage
.from("case-documents")
.createSignedUrl(doc.storage_path, 300);
setPreviewLoading(false);
if (error) {
toast.error(error.message);
setPreviewDoc(null);
return;
}
setPreviewUrl(data.signedUrl);
};
const closePreview = () => {
setPreviewDoc(null);
setPreviewUrl(null);
};
const clearAll = () => {
setQ("");
setCaseFilter(new Set());
setUploaderFilter(new Set());
setTypeFilter(new Set());
setFolderFilter(new Set());
setDateRange("any");
};
const activeCount =
caseFilter.size + uploaderFilter.size + typeFilter.size + folderFilter.size + (dateRange !== "any" ? 1 : 0);
return (
<PageContainer>
<PageHeader title="Files" description="All documents uploaded across the cases you can access." />
<Card className="border-border/60 mb-4">
<CardContent className="p-3 space-y-3">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by file, folder, case, client, or uploader…"
className="border-0 focus-visible:ring-0 shadow-none"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{filtered.length} of {docs.length}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterPopover
label="Case"
selected={caseFilter}
options={caseOptions}
onChange={setCaseFilter}
/>
<FilterPopover
label="Uploader"
selected={uploaderFilter}
options={uploaderOptions}
onChange={setUploaderFilter}
/>
<FilterPopover
label="Type"
selected={typeFilter}
options={TYPE_BUCKETS.map((t) => ({ id: t.key, label: t.label }))}
onChange={setTypeFilter}
/>
<FilterPopover
label="Folder"
selected={folderFilter}
options={folderOptions.map((f) => ({ id: f, label: f || "(root)" }))}
onChange={setFolderFilter}
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Filter className="h-3.5 w-3.5 mr-1.5" />
Date: {DATE_RANGES.find((r) => r.key === dateRange)?.label}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
{DATE_RANGES.map((r) => (
<button
key={r.key}
onClick={() => setDateRange(r.key)}
className={`w-full text-left text-sm px-2 py-1.5 rounded hover:bg-muted ${dateRange === r.key ? "bg-muted font-medium" : ""}`}
>
{r.label}
</button>
))}
</PopoverContent>
</Popover>
{activeCount > 0 && (
<Button variant="ghost" size="sm" className="h-8 text-muted-foreground" onClick={clearAll}>
<X className="h-3.5 w-3.5 mr-1" />
Clear all
</Button>
)}
</div>
{/* Active filter chips */}
{activeCount > 0 && (
<div className="flex flex-wrap gap-1.5">
{Array.from(caseFilter).map((id) => {
const o = caseOptions.find((c) => c.id === id);
return (
<FilterChip
key={`c-${id}`}
label={`Case: ${o?.label ?? id}`}
onRemove={() => {
const n = new Set(caseFilter);
n.delete(id);
setCaseFilter(n);
}}
/>
);
})}
{Array.from(uploaderFilter).map((id) => {
const o = uploaderOptions.find((u) => u.id === id);
return (
<FilterChip
key={`u-${id}`}
label={`By: ${o?.label ?? id}`}
onRemove={() => {
const n = new Set(uploaderFilter);
n.delete(id);
setUploaderFilter(n);
}}
/>
);
})}
{Array.from(typeFilter).map((id) => {
const o = TYPE_BUCKETS.find((t) => t.key === id);
return (
<FilterChip
key={`t-${id}`}
label={`Type: ${o?.label ?? id}`}
onRemove={() => {
const n = new Set(typeFilter);
n.delete(id);
setTypeFilter(n);
}}
/>
);
})}
{Array.from(folderFilter).map((id) => (
<FilterChip
key={`f-${id}`}
label={`Folder: ${id || "(root)"}`}
onRemove={() => {
const n = new Set(folderFilter);
n.delete(id);
setFolderFilter(n);
}}
/>
))}
{dateRange !== "any" && (
<FilterChip
label={`Date: ${DATE_RANGES.find((r) => r.key === dateRange)?.label}`}
onRemove={() => setDateRange("any")}
/>
)}
</div>
)}
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Case</th>
<th className="text-left px-4 py-3 font-medium">Folder</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-right px-4 py-3 font-medium">Size</th>
<th></th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td colSpan={7} className="text-center py-12 text-muted-foreground">
Loading…
</td>
</tr>
)}
{!loading && filtered.length === 0 && (
<tr>
<td colSpan={7} className="text-center py-12 text-muted-foreground">
No files match these filters.
</td>
</tr>
)}
{filtered.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{d.name}</span>
</div>
</td>
<td className="px-4 py-3">
{d.case ? (
<Link to="/cases/$caseId" params={{ caseId: d.case.id }} className="hover:text-primary">
<div className="text-sm">{d.case.title}</div>
<div className="text-[11px] text-muted-foreground">
{d.case.case_number}
{d.case.client ? ` · ${d.case.client.name}` : ""}
</div>
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{d.folder ? (
<span className="inline-flex items-center gap-1.5">
<Folder className="h-3.5 w-3.5" />
{d.folder}
</span>
) : (
<span className="text-xs">root</span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{d.uploader?.full_name || d.uploader?.email || "—"}
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(d.created_at)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">
{d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : "—"}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => openPreview(d)} title="Quick preview">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => download(d)} title="Download">
<Download className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Dialog open={!!previewDoc} onOpenChange={(o) => !o && closePreview()}>
<DialogContent className="max-w-5xl w-[95vw] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-4 py-3 border-b flex-row items-center justify-between space-y-0">
<DialogTitle className="truncate pr-4">{previewDoc?.name}</DialogTitle>
{previewUrl && (
<div className="flex items-center gap-1 mr-6">
<Button variant="ghost" size="sm" onClick={() => window.open(previewUrl, "_blank")}>
<ExternalLink className="h-4 w-4 mr-1.5" /> Open
</Button>
<Button variant="ghost" size="sm" onClick={() => previewDoc && download(previewDoc)}>
<Download className="h-4 w-4 mr-1.5" /> Download
</Button>
</div>
)}
</DialogHeader>
<div className="flex-1 min-h-0 bg-muted/30">
{previewLoading ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading preview…
</div>
) : previewUrl && previewDoc ? (
<PreviewBody url={previewUrl} doc={previewDoc} />
) : null}
</div>
</DialogContent>
</Dialog>
</PageContainer>
);
}
function FilterPopover({
label,
selected,
options,
onChange,
}: {
label: string;
selected: Set<string>;
options: { id: string; label: string }[];
onChange: (s: Set<string>) => void;
}) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const s = search.trim().toLowerCase();
if (!s) return options;
return options.filter((o) => o.label.toLowerCase().includes(s));
}, [options, search]);
const toggle = (id: string) => {
const n = new Set(selected);
if (n.has(id)) n.delete(id);
else n.add(id);
onChange(n);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Filter className="h-3.5 w-3.5 mr-1.5" />
{label}
{selected.size > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5">
{selected.size}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-2">
{options.length > 8 && (
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={`Search ${label.toLowerCase()}…`}
className="h-8 mb-2"
/>
)}
<div className="max-h-64 overflow-auto space-y-0.5">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-2 py-3 text-center">No options</p>
) : (
filtered.map((o) => (
<label
key={o.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer text-sm"
>
<Checkbox checked={selected.has(o.id)} onCheckedChange={() => toggle(o.id)} />
<span className="truncate">{o.label}</span>
</label>
))
)}
</div>
{selected.size > 0 && (
<div className="border-t mt-2 pt-2">
<Button variant="ghost" size="sm" className="w-full h-7" onClick={() => onChange(new Set())}>
Clear
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<Badge variant="secondary" className="gap-1 pr-1">
<span className="truncate max-w-[200px]">{label}</span>
<button onClick={onRemove} className="hover:bg-background/50 rounded p-0.5">
<X className="h-3 w-3" />
</button>
</Badge>
);
}
function PreviewBody({ url, doc }: { url: string; doc: any }) {
const mime = (doc.mime_type || "").toLowerCase();
const name = (doc.name || "").toLowerCase();
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name);
const isPdf = mime === "application/pdf" || name.endsWith(".pdf");
const isText = mime.startsWith("text/") || /\.(txt|md|csv|json|log|xml|yml|yaml)$/i.test(name);
const isAudio = mime.startsWith("audio/") || /\.(mp3|wav|m4a|ogg)$/i.test(name);
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|mov)$/i.test(name);
if (isImage) {
return (
<div className="h-full w-full flex items-center justify-center overflow-auto p-4">
<img src={url} alt={doc.name} className="max-h-full max-w-full object-contain" />
</div>
);
}
if (isPdf || isText) {
return <iframe src={url} title={doc.name} className="h-full w-full border-0 bg-white" />;
}
if (isAudio) {
return (
<div className="h-full flex items-center justify-center p-4">
<audio controls src={url} className="w-full max-w-xl" />
</div>
);
}
if (isVideo) {
return (
<div className="h-full flex items-center justify-center p-4 bg-black">
<video controls src={url} className="max-h-full max-w-full" />
</div>
);
}
return (
<div className="h-full flex flex-col items-center justify-center text-center p-8 text-muted-foreground">
<FileText className="h-10 w-10 mb-3 opacity-60" />
<p className="mb-1">No inline preview available for this file type.</p>
<p className="text-xs mb-4">{doc.mime_type || "Unknown type"}</p>
<Button onClick={() => window.open(url, "_blank")}>
<ExternalLink className="h-4 w-4 mr-1.5" /> Open in new tab
</Button>
</div>
);
}