feat: trash (soft delete) with admin tool management
Build & Push Docker Image / build (push) Successful in 2m15s
Build & Push Docker Image / build (push) Successful in 2m15s
- tools: add deletedAt/deletedBy, soft delete via DELETE /tools/:id when actor has trash entitlement, else immediate hard delete - trash endpoints: GET /tools/trash, POST /tools/trash (admin bulk), POST /tools/trash/restore, DELETE /tools/trash, POST /tools/trash/empty - trash feature for premium/enterprise; exclude trashed from all public surfaces (browse, categories, features, tags, similar, ratings, costs, analytics, redundancy) - TRASH_RETENTION_DAYS env (0 = keep forever) with hourly purge job - frontend: /trash page (premium+, restore for all, permanent delete + empty for admin), admin Tools tab with multi-select bulk trash, sidebar Trash link, tool-detail delete hint
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import {
|
||||
useListTools,
|
||||
useTrashTools,
|
||||
getListToolsQueryKey,
|
||||
getListTrashedToolsQueryKey,
|
||||
getListCategoriesQueryKey,
|
||||
getListAllFeaturesQueryKey,
|
||||
getListAllTagsQueryKey,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
type ToolWithStats,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
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 { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function AdminToolsTab() {
|
||||
const { isAdmin, 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 [confirmTrash, setConfirmTrash] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
const { data: tools, isLoading: loading } = useListTools(
|
||||
search ? { search } : undefined,
|
||||
{ query: { queryKey: getListToolsQueryKey(search ? { search } : undefined), enabled: isAdmin } },
|
||||
);
|
||||
|
||||
const trash = useTrashTools();
|
||||
|
||||
const allTools = tools ?? [];
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListTrashedToolsQueryKey() });
|
||||
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 === allTools.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(allTools.map((t) => t.id)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrash(ids: number[]) {
|
||||
trash.mutate(
|
||||
{ data: { ids } },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
|
||||
setSelected(new Set());
|
||||
setConfirmTrash(false);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!authLoading && !isAdmin) {
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
|
||||
}
|
||||
|
||||
const selectedIds = [...selected];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>All Tools</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
|
||||
</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="Search tools…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={selectedIds.length === 0 || trash.isPending}
|
||||
onClick={() => setConfirmTrash(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : allTools.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={selected.size === allTools.length && allTools.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Rating</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allTools.map((t: ToolWithStats) => (
|
||||
<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.ratingCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
|
||||
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(t.createdAt), "dd.MM.yyyy")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleTrash([t.id])}
|
||||
disabled={trash.isPending}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleTrash(selectedIds)}
|
||||
>
|
||||
Move to trash
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle } from "lucide-react";
|
||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -7,13 +7,14 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, login, logout } = useAuth();
|
||||
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
||||
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
|
||||
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
||||
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user