Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
d771070df9
commit
aaa92f98ca
+471
-34
@@ -5,8 +5,31 @@ 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 } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Search,
|
||||
Folder,
|
||||
Filter,
|
||||
X,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -18,11 +41,50 @@ export const Route = createFileRoute("/files/")({
|
||||
),
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -30,7 +92,11 @@ function FilesPage() {
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1000);
|
||||
if (error) { toast.error("Could not load files", { description: error.message }); setLoading(false); return; }
|
||||
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)));
|
||||
@@ -52,48 +118,239 @@ function FilesPage() {
|
||||
...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(); }, []);
|
||||
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();
|
||||
if (!s) return docs;
|
||||
return docs.filter((d) =>
|
||||
[d.name, d.folder, d.case?.case_number, d.case?.title, d.case?.client?.name, d.uploader?.full_name, d.uploader?.email]
|
||||
.filter(Boolean)
|
||||
.some((v: string) => String(v).toLowerCase().includes(s)),
|
||||
);
|
||||
}, [docs, q]);
|
||||
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; }
|
||||
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."
|
||||
/>
|
||||
<PageHeader title="Files" description="All documents uploaded across the cases you can access." />
|
||||
|
||||
<Card className="border-border/60 mb-4">
|
||||
<CardContent className="p-3 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>
|
||||
<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>
|
||||
|
||||
@@ -113,10 +370,18 @@ function FilesPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>
|
||||
<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 found.</td></tr>
|
||||
<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">
|
||||
@@ -131,23 +396,38 @@ function FilesPage() {
|
||||
<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}` : ""}
|
||||
{d.case.case_number}
|
||||
{d.case.client ? ` · ${d.case.client.name}` : ""}
|
||||
</div>
|
||||
</Link>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
) : (
|
||||
<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>}
|
||||
<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">{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={() => download(d)}><Download className="h-4 w-4" /></Button>
|
||||
<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>
|
||||
))}
|
||||
@@ -155,6 +435,163 @@ function FilesPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user