Files
backerup-website/frontend/app/browse/BrowseClient.tsx
T
Anders Böttcher 0d58fafe4e feat: implement user authentication and admin management
- 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.
2026-05-12 14:58:11 +02:00

318 lines
11 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 } 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 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 === 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>
);
}