mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
"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 A–Z</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>
|
||
);
|
||
}
|