Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
598 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|