mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
0d58fafe4e
- Add user management API endpoints for listing and creating users. - Implement login and logout functionality with rate limiting. - Create UI components for login form, logout button, and animated elements. - Add session management with JWT and secure cookie handling. - Seed initial admin user if no users exist. - Introduce utility functions for password hashing and user authentication. - Set up proxy middleware for route protection and security headers. - Create a JSON file for user data storage. - Add links configuration for external resources.
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } 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>
|
||
);
|
||
}
|
||
|
||
export default function BrowseClient({
|
||
records: allRecords,
|
||
groups,
|
||
}: {
|
||
records: FileRecord[];
|
||
groups: string[];
|
||
}) {
|
||
const [search, setSearch] = useState("");
|
||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||
const [sort, setSort] = useState<SortOrder>("newest");
|
||
|
||
const filtered = useMemo(() => {
|
||
let r = allRecords;
|
||
|
||
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;
|
||
}, [allRecords, 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 === allRecords.length
|
||
? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community`
|
||
: `${filtered.length} of ${allRecords.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) => (
|
||
<Link
|
||
key={r.token}
|
||
href={`/files/${r.token}`}
|
||
className="group rounded-2xl border border-white/5 glass p-5 card-hover block"
|
||
>
|
||
<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>
|
||
|
||
{/* Meta row */}
|
||
<div className="mt-4 flex items-center gap-3 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>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|