feat: implement file deletion functionality and enhance deployment script

This commit is contained in:
Anders Böttcher
2026-05-13 12:35:58 +02:00
parent cf23467be6
commit 9dbbf814b4
10 changed files with 229 additions and 50 deletions
+20 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { getFile } from '@/lib/store'
import { getFile, deleteFile } from '@/lib/store'
import { getSession } from '@/lib/session'
export async function GET(
_request: Request,
@@ -12,3 +13,21 @@ export async function GET(
}
return NextResponse.json(record)
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const session = await getSession()
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { token } = await params
try {
await deleteFile(token)
return NextResponse.json({ ok: true })
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 404 })
}
}
+70 -24
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import Link from "next/link";
import type { FileRecord } from "@/lib/store";
@@ -54,20 +54,52 @@ function SortIcon() {
);
}
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 = allRecords;
let r = records;
if (search.trim()) {
const q = search.toLowerCase();
@@ -107,7 +139,7 @@ export default function BrowseClient({
}
return sorted;
}, [allRecords, search, selectedGroup, typeFilter, sort]);
}, [records, search, selectedGroup, typeFilter, sort]);
const activeFilters =
(search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0);
@@ -218,9 +250,9 @@ export default function BrowseClient({
{/* ── 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`}
{filtered.length === records.length
? `${records.length} file${records.length !== 1 ? "s" : ""} shared by the community`
: `${filtered.length} of ${records.length} files`}
</p>
{/* ── Cards ── */}
@@ -241,11 +273,11 @@ export default function BrowseClient({
) : (
<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 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 */}
@@ -295,20 +327,34 @@ export default function BrowseClient({
<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>
{/* 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>
)}
+7 -2
View File
@@ -1,9 +1,14 @@
import Link from "next/link";
import { readMeta, listGroups } from "@/lib/store";
import { getSession } from "@/lib/session";
import BrowseClient from "./BrowseClient";
export default async function BrowsePage() {
const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]);
const [allRecords, groups, session] = await Promise.all([
readMeta(),
listGroups(),
getSession(),
]);
return (
<div className="mx-auto max-w-5xl px-6 py-16">
@@ -31,7 +36,7 @@ export default async function BrowsePage() {
</div>
{/* Client section: search + filter + cards */}
<BrowseClient records={allRecords} groups={groups} />
<BrowseClient records={allRecords} groups={groups} isAdmin={session?.role === "admin"} />
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function DeleteFileButton({ token, name }: { token: string; name: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleDelete() {
if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return;
setLoading(true);
try {
const res = await fetch(`/api/files/${token}`, { method: "DELETE" });
if (res.ok) {
router.push("/browse");
router.refresh();
} 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 {
setLoading(false);
}
}
return (
<button
onClick={handleDelete}
disabled={loading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl border py-3 text-sm font-semibold transition-all disabled:opacity-50"
style={{
borderColor: "rgba(239,68,68,0.3)",
background: "rgba(239,68,68,0.06)",
color: "#fca5a5",
}}
>
{loading ? (
<>
<span className="w-4 h-4 rounded-full border border-rose-400 border-t-transparent animate-spin" />
Deleting
</>
) : (
<>
<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="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>
Delete file
</>
)}
</button>
);
}
+8 -1
View File
@@ -1,7 +1,9 @@
import Link from "next/link";
import { getFile } from "@/lib/store";
import { notFound } from "next/navigation";
import { getSession } from "@/lib/session";
import CopyLinkButton from "./CopyLinkButton";
import DeleteFileButton from "./DeleteFileButton";
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
@@ -24,8 +26,9 @@ export default async function FileDetailPage({
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const record = await getFile(token);
const [record, session] = await Promise.all([getFile(token), getSession()]);
if (!record) notFound();
const isAdmin = session?.role === "admin";
const isBackerup = record.type === "backerup";
const downloadUrl = `/api/files/${token}/download`;
@@ -138,6 +141,10 @@ export default async function FileDetailPage({
<CopyLinkButton token={token} />
{isAdmin && (
<DeleteFileButton token={token} name={title} />
)}
<Link
href="/browse"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/3 py-3 text-sm font-medium text-slate-400 hover:text-white hover:bg-white/6 transition-all"
+1 -2
View File
@@ -34,9 +34,8 @@ export default function LoginForm({ from }: Props) {
setLoading(false)
return
}
// Navigate to intended destination or browse
// Navigate to intended destination — fresh page load picks up the session cookie
router.push(from || '/browse')
router.refresh()
} catch {
setError('Network error — please try again')
setLoading(false)