329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import {
|
|
useListTrashedTools,
|
|
useRestoreTools,
|
|
useDeleteTrashedTools,
|
|
useEmptyTrash,
|
|
getListTrashedToolsQueryKey,
|
|
getListToolsQueryKey,
|
|
getListCategoriesQueryKey,
|
|
getListAllFeaturesQueryKey,
|
|
getListAllTagsQueryKey,
|
|
getGetTopToolsQueryKey,
|
|
getGetAnalyticsSummaryQueryKey,
|
|
} 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: t("trash.toastRestored"), description: t("trash.toastRestoredSub", { count: res.restored ?? ids.length }) });
|
|
setSelected(new Set());
|
|
invalidate();
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("trash.toastRestoreFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleDeletePermanent(ids: number[]) {
|
|
deletePermanent.mutate(
|
|
{ data: { ids } },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: t("trash.toastDeleted"), description: t("trash.toastDeletedSub", { count: ids.length }) });
|
|
setSelected(new Set());
|
|
setConfirmDelete(false);
|
|
invalidate();
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("trash.toastDeleteFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleEmpty() {
|
|
empty.mutate(
|
|
undefined,
|
|
{
|
|
onSuccess: (res) => {
|
|
toast({ title: t("trash.toastEmptied"), description: t("trash.toastEmptiedSub", { count: res.deleted ?? 0 }) });
|
|
setSelected(new Set());
|
|
setConfirmEmpty(false);
|
|
invalidate();
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("trash.toastEmptyFailed"), 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="outline"
|
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
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={t("trash.selectAll")}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>{t("trash.name")}</TableHead>
|
|
<TableHead>{t("trash.category")}</TableHead>
|
|
<TableHead>{t("trash.deletedAt")}</TableHead>
|
|
<TableHead>{t("trash.deletedBy")}</TableHead>
|
|
<TableHead className="text-right">{t("trash.actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{trashed.map((tool) => (
|
|
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selected.has(tool.id)}
|
|
onCheckedChange={() => toggle(tool.id)}
|
|
aria-label={t("trash.selectName", { name: tool.name })}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{tool.name}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{tool.category}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{tool.deletedAt ? format(new Date(tool.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
|
|
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
|
|
</Button>
|
|
{isAdmin && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => handleDeletePermanent([tool.id])}
|
|
disabled={deletePermanent.isPending}
|
|
>
|
|
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("trash.deleteConfirmSub")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("common.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>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("trash.emptyConfirmSub", { count: trashed.length })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={handleEmpty}
|
|
>
|
|
{t("trash.emptyTrash")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Layout>
|
|
);
|
|
}
|