Files
tool-evaluator/artifacts/toolrate/src/pages/trash.tsx
T
opencode 2f38889726
Build & Push Docker Image / build (push) Successful in 2m31s
fix: compact filter popover; feat: i18n (de/en)
2026-08-02 13:42:53 +02:00

327 lines
13 KiB
TypeScript

import { useEffect, useState } from "react";
import {
useListTrashedTools,
useRestoreTools,
useDeleteTrashedTools,
useEmptyTrash,
getListTrashedToolsQueryKey,
getListToolsQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey,
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type Tool,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
export default function Trash() {
const { t } = useTranslation();
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmEmpty, setConfirmEmpty] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(t);
}, [searchInput]);
const hasTrash = hasFeature("trash");
const { data: tools, isLoading: loading } = useListTrashedTools(
search ? { search } : undefined,
{ query: { queryKey: getListTrashedToolsQueryKey(search ? { search } : undefined), enabled: hasTrash } },
);
const restore = useRestoreTools();
const deletePermanent = useDeleteTrashedTools();
const empty = useEmptyTrash();
const trashed = tools ?? [];
function invalidate() {
queryClient.invalidateQueries({ queryKey: getListTrashedToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
}
function toggle(id: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === trashed.length) {
setSelected(new Set());
} else {
setSelected(new Set(trashed.map((t) => t.id)));
}
}
function handleRestore(ids: number[]) {
restore.mutate(
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
setSelected(new Set());
invalidate();
},
onError: (err) => {
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
function handleDeletePermanent(ids: number[]) {
deletePermanent.mutate(
{ data: { ids } },
{
onSuccess: () => {
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
setSelected(new Set());
setConfirmDelete(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
function handleEmpty() {
empty.mutate(
undefined,
{
onSuccess: (res) => {
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
setSelected(new Set());
setConfirmEmpty(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
if (!authLoading && !hasTrash) {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
<Button variant="outline" asChild>
<a href="/tools">{t("common.backToTools")}</a>
</Button>
</div>
</Layout>
);
}
const selectedIds = [...selected];
return (
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>{t("trash.trashedTools")}</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? t("trash.selected", { count: selectedIds.length }) : t("trash.toolsInTrash", { count: trashed.length })}
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8 w-56"
placeholder={`${t("common.search")}`}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={selectedIds.length === 0 || restore.isPending}
onClick={() => handleRestore(selectedIds)}
>
<RotateCcw className="w-4 h-4 mr-2" /> {t("trash.restore")} ({selectedIds.length})
</Button>
{isAdmin && (
<>
<Button
size="sm"
variant="destructive"
disabled={selectedIds.length === 0 || deletePermanent.isPending}
onClick={() => setConfirmDelete(true)}
>
<TrashIcon className="w-4 h-4 mr-2" /> {t("trash.deletePermanently")} ({selectedIds.length})
</Button>
<Button
size="sm"
variant="outline"
disabled={trashed.length === 0 || empty.isPending}
onClick={() => setConfirmEmpty(true)}
>
<Trash2 className="w-4 h-4 mr-2" /> {t("trash.emptyTrash")}
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : trashed.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
{search ? t("trash.noMatch") : t("trash.empty")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selected.size === trashed.length && trashed.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>{t("trash.deletedAt")}</TableHead>
<TableHead>{t("trash.deletedBy")}</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trashed.map((t: Tool) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> Restore
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDeletePermanent([t.id])}
disabled={deletePermanent.isPending}
>
<TrashIcon className="w-3.5 h-3.5" /> Delete
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)}
>
{t("trash.deletePermanently")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty}
>
{t("trash.emptyTrash")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}