Files
backerup-website/frontend/app/browse/BrowseClient.tsx
T

364 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo, useCallback } from "react";
import Link from "next/link";
import type { FileRecord } from "@/lib/store";
type SortOrder = "newest" | "oldest" | "name" | "size-desc" | "size-asc";
type TypeFilter = "all" | "backerup" | "config";
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
function formatDate(s: string) {
return new Date(s).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
);
}
function XIcon() {
return (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function SortIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h18M6 12h12M10 17h4" />
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
);
}
export default function BrowseClient({
records: allRecords,
groups,
isAdmin = false,
}: {
records: FileRecord[];
groups: string[];
isAdmin?: boolean;
}) {
const [records, setRecords] = useState<FileRecord[]>(allRecords);
const [search, setSearch] = useState("");
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortOrder>("newest");
const [deleting, setDeleting] = useState<string | null>(null);
const handleDelete = useCallback(async (token: string, name: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return;
setDeleting(token);
try {
const res = await fetch(`/api/files/${token}`, { method: "DELETE" });
if (res.ok) {
setRecords((prev) => prev.filter((r) => r.token !== token));
} else {
const data = await res.json().catch(() => ({})) as { error?: string };
alert(data.error ?? `Delete failed (${res.status})`);
}
} catch {
alert("Network error could not delete file");
} finally {
setDeleting(null);
}
}, []);
const filtered = useMemo(() => {
let r = records;
if (search.trim()) {
const q = search.toLowerCase();
r = r.filter(
(rec) =>
rec.displayName?.toLowerCase().includes(q) ||
rec.originalName.toLowerCase().includes(q) ||
rec.description?.toLowerCase().includes(q) ||
rec.containerName?.toLowerCase().includes(q) ||
rec.image?.toLowerCase().includes(q) ||
rec.group?.toLowerCase().includes(q)
);
}
if (selectedGroup) r = r.filter((rec) => rec.group === selectedGroup);
if (typeFilter !== "all") r = r.filter((rec) => rec.type === typeFilter);
const sorted = [...r];
switch (sort) {
case "newest":
sorted.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime());
break;
case "oldest":
sorted.sort((a, b) => new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime());
break;
case "name":
sorted.sort((a, b) =>
(a.displayName ?? a.originalName).localeCompare(b.displayName ?? b.originalName)
);
break;
case "size-desc":
sorted.sort((a, b) => b.size - a.size);
break;
case "size-asc":
sorted.sort((a, b) => a.size - b.size);
break;
}
return sorted;
}, [records, search, selectedGroup, typeFilter, sort]);
const activeFilters =
(search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0);
function clearAll() {
setSearch("");
setSelectedGroup(null);
setTypeFilter("all");
setSort("newest");
}
return (
<div>
{/* ── Search + Sort row ── */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<SearchIcon />
</span>
<input
type="search"
placeholder="Search by name, image, description, group…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-white/8 bg-white/4 pl-10 pr-4 py-2.5 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/40 focus:bg-white/6 transition-all"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
<XIcon />
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-slate-600 shrink-0">
<SortIcon />
</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
className="rounded-xl border border-white/8 bg-slate-900 px-3 py-2.5 text-sm text-slate-300 outline-none focus:border-cyan-500/40 transition-all cursor-pointer"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name AZ</option>
<option value="size-desc">Largest first</option>
<option value="size-asc">Smallest first</option>
</select>
</div>
</div>
{/* ── Filter pills ── */}
<div className="mb-8 flex flex-wrap items-center gap-2">
{/* Type pills */}
{(["all", "backerup", "config"] as TypeFilter[]).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
typeFilter === t
? t === "all"
? "bg-cyan-500 text-slate-900"
: t === "backerup"
? "bg-cyan-500/20 border border-cyan-500/50 text-cyan-300"
: "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`}
>
{t === "all" ? "All types" : t === "backerup" ? ".backerup" : ".json config"}
</button>
))}
{groups.length > 0 && (
<>
<div className="h-5 w-px bg-white/8 mx-1" />
{groups.map((g) => (
<button
key={g}
onClick={() => setSelectedGroup(selectedGroup === g ? null : g)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
selectedGroup === g
? "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`}
>
{selectedGroup === g && (
<span className="mr-1 inline-block w-1.5 h-1.5 rounded-full bg-violet-400" />
)}
{g}
</button>
))}
</>
)}
{activeFilters > 0 && (
<button
onClick={clearAll}
className="ml-auto flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<XIcon />
Clear filters ({activeFilters})
</button>
)}
</div>
{/* ── Results count ── */}
<p className="mb-5 text-xs text-slate-600">
{filtered.length === records.length
? `${records.length} file${records.length !== 1 ? "s" : ""} shared by the community`
: `${filtered.length} of ${records.length} files`}
</p>
{/* ── Cards ── */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-white/5 glass p-20 text-center">
<div className="text-5xl mb-5">🔍</div>
<p className="font-semibold text-white">No results found</p>
<p className="mt-2 text-sm text-slate-500">Try adjusting your search or clearing some filters.</p>
{activeFilters > 0 && (
<button
onClick={clearAll}
className="mt-5 text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
>
Clear all filters
</button>
)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((r) => (
<div key={r.token} className="group/card rounded-2xl border border-white/5 glass card-hover overflow-hidden">
<Link
href={`/files/${r.token}`}
className="group block px-5 pt-5 pb-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2">
{r.type === "backerup" ? (
<span className="rounded-full bg-cyan-500/12 border border-cyan-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-cyan-400">
.backerup
</span>
) : (
<span className="rounded-full bg-violet-500/12 border border-violet-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-violet-400">
.json config
</span>
)}
{r.group && (
<span className="rounded-full bg-white/4 border border-white/8 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{r.group}
</span>
)}
</div>
{/* Title */}
{(r.displayName ?? r.containerName) ? (
<p className="mt-2.5 font-semibold text-white truncate group-hover:text-cyan-300 transition-colors">
{r.displayName ?? r.containerName}
</p>
) : (
<p className="mt-2.5 font-mono text-sm text-slate-400 truncate">
{r.originalName}
</p>
)}
{/* Image */}
{r.image && (
<p className="mt-0.5 font-mono text-xs text-slate-500 truncate">{r.image}</p>
)}
{/* Description */}
{r.description && (
<p className="mt-2 text-sm text-slate-400 line-clamp-2 leading-relaxed">
{r.description}
</p>
)}
</div>
{/* Arrow */}
<span className="shrink-0 text-slate-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all mt-1">
<ArrowIcon />
</span>
</div>
</Link>
{/* Card footer: meta row + admin delete inline */}
<div className="flex items-center gap-3 px-5 pb-4 text-xs text-slate-600">
<span>{formatBytes(r.size)}</span>
<span className="text-slate-800">·</span>
<span>{formatDate(r.uploadedAt)}</span>
{r.containerName && r.displayName && (
<>
<span className="text-slate-800">·</span>
<span className="font-mono truncate">{r.containerName}</span>
</>
)}
{isAdmin && (
<button
onClick={(e) => handleDelete(r.token, r.displayName ?? r.containerName ?? r.originalName, e)}
disabled={deleting === r.token}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 opacity-0 group-hover/card:opacity-100 transition-opacity hover:text-rose-400 hover:bg-rose-500/10 active:scale-95 disabled:opacity-40 text-slate-600"
title="Delete file"
aria-label="Delete file"
>
{deleting === r.token
? <span className="w-3 h-3 rounded-full border border-rose-400 border-t-transparent animate-spin" />
: <TrashIcon />}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}