feat(dashboard): implement trash view with restore and permanent delete actions

This commit is contained in:
Chneemann 2026-08-11 17:02:12 +02:00
parent de64b94869
commit 48d37c7907
No known key found for this signature in database
7 changed files with 316 additions and 11 deletions

View file

@ -13,13 +13,20 @@ import KanbanBoardHeader from "./components/KanbanBoardHeader";
/**
* Renders the responsive grid container of kanban columns, coordinating state tracking
* for active task updates/deletions and triggering mutation API requests.
* for active task updates/deletions, trash counts, and triggering mutation API requests.
*
* @param {Object} props - The component props.
* @param {Task[]} props.tasks - The array of task items displayed across the board.
* @param {number} props.trashCount - The count of items currently in the trash bin.
* @returns {JSX.Element} The rendered kanban board component.
*/
export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
export default function KanbanBoard({
tasks,
trashCount,
}: {
tasks: Task[];
trashCount: number;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
@ -93,7 +100,7 @@ export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
return (
<div className="space-y-8">
{/* Workspace Header */}
<KanbanBoardHeader onTaskDelete={deleteTask} />
<KanbanBoardHeader onTaskDelete={deleteTask} trashCount={trashCount} />
{/* Kanban Columns Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">

View file

@ -1,25 +1,29 @@
/**
* @file dashboard/components/KanbanBoardHeader.tsx
* @description Component rendering the top title header with an integrated delete drop zone that appears during drag-and-drop interactions.
* @description Component rendering the top title header with an integrated delete drop zone that appears during drag-and-drop interactions, plus a link to the trash bin view.
*/
"use client";
import { useState, useEffect } from "react";
import { Plus, Sparkles, Trash2 } from "lucide-react";
import Link from "next/link";
/**
* Renders the dashboard header featuring a workspace title, subtitle, new task action button,
* and a dynamic drop zone for deleting tasks during drag operations.
* a dynamic delete drop zone during drag operations, and a trash navigation link with a counter badge.
*
* @param {Object} props - The component props.
* @param {(taskId: string) => void} props.onTaskDelete - Callback function invoked when a task is dropped into the delete zone.
* @param {number} props.trashCount - The current count of items in the trash bin.
* @returns {JSX.Element} The rendered kanban board header component.
*/
export default function KanbanBoardHeader({
onTaskDelete,
trashCount,
}: {
onTaskDelete: (taskId: string) => void;
trashCount: number;
}) {
const [isDragging, setIsDragging] = useState(false);
const [isOver, setIsOver] = useState(false);
@ -86,6 +90,24 @@ export default function KanbanBoardHeader({
New Task
</button>
)}
<Link
href="/trash"
className="relative group p-2 rounded-xl border border-border hover:border-primary transition-all duration-200"
title="View Trash"
>
{/* Icon */}
<Trash2
size={16}
className="text-foreground-muted group-hover:text-primary transition-colors"
/>
{/* Badge */}
{trashCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-destructive text-[9px] font-bold text-white shadow-sm ring-1 ring-background">
{trashCount}
</span>
)}
</Link>
</div>
</div>
);

View file

@ -1,11 +1,11 @@
/**
* @file dashboard/page.tsx
* @description Server component rendering the main dashboard page, handling authentication, fetching user-related tasks and assignees, and passing them to the board.
* @description Server component rendering the main dashboard page, handling authentication, fetching active user-related tasks and assignees, computing trash counts, and passing data to the kanban board container.
*/
import { db } from "@/db";
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
import { eq, inArray, or } from "drizzle-orm";
import { and, count, eq, inArray, isNotNull, isNull, or } from "drizzle-orm";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import KanbanBoard from "./KanbanBoard";
@ -13,7 +13,8 @@ import { Task } from "@/types/tasks";
/**
* Renders the dashboard page component with user session validation,
* database queries for relevant tasks and team assignees, and passes the structured dataset to the Kanban board container.
* database queries for active tasks, team assignees, and soft-deleted trash counts,
* before passing the structured dataset to the Kanban board container.
*
* @async
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
@ -33,13 +34,15 @@ export default async function Dashboard() {
const assignedTaskIds = assignedTaskRows.map((r) => r.taskId);
const taskWhereClause =
const taskWhereClause = and(
isNull(tasksTable.deletedAt),
assignedTaskIds.length > 0
? or(
eq(tasksTable.userId, currentUserId),
inArray(tasksTable.id, assignedTaskIds),
)
: eq(tasksTable.userId, currentUserId);
: eq(tasksTable.userId, currentUserId),
);
const rawTasksWithCreator = await db
.select({
@ -78,9 +81,21 @@ export default async function Dashboard() {
commentsCount: 0,
}));
const [trashCountResult] = await db
.select({ count: count() })
.from(tasksTable)
.where(
and(
eq(tasksTable.userId, currentUserId),
isNotNull(tasksTable.deletedAt),
),
);
const trashCount = trashCountResult.count;
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<KanbanBoard tasks={tasks} />
<KanbanBoard tasks={tasks} trashCount={trashCount} />
</div>
);
}

View file

@ -0,0 +1,92 @@
/**
* @file trash/TrashList.tsx
* @description Client component rendering the list of deleted tasks with rich metadata, priority badges, and restore/delete actions.
*/
"use client";
import { Task, PRIORITY_CONFIG } from "@/types/tasks";
import { Calendar } from "lucide-react";
import TaskActionButton from "./components/TaskActionButton";
/**
* Renders a list of deleted tasks stored in the trash, featuring priority badges,
* deletion dates, and action controls for permanent deletion or restoration.
*
* @param {Object} props - The component props.
* @param {Task[]} props.tasks - The array of deleted tasks to render.
* @returns {JSX.Element} The rendered trash list component or an empty state placeholder.
*/
export default function TrashList({ tasks }: { tasks: Task[] }) {
return (
<div className="grid gap-4">
{tasks.length === 0 ? (
<div className="text-center py-20 border-2 border-dashed border-border/50 rounded-3xl bg-card/20 space-y-2">
<p className="text-sm font-medium text-foreground-muted">
No deleted tasks found.
</p>
<p className="text-xs text-foreground-muted/65">
Your trash is completely empty.
</p>
</div>
) : (
tasks.map((task) => {
const priorityConfig =
PRIORITY_CONFIG[task.priority as keyof typeof PRIORITY_CONFIG] ||
PRIORITY_CONFIG.medium;
const deletedDate = task.deletedAt
? new Date(task.deletedAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: "Unknown";
return (
<div
key={task.id}
className="group relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-card/40 border border-border/80 rounded-2xl hover:border-primary/40 hover:bg-card/70 transition-all duration-200 shadow-sm overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-rose-500/40 group-hover:bg-rose-500 transition-colors" />
{/* --- Left Side --- */}
<div className="flex flex-col gap-2 pl-2 max-w-2xl">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-base group-hover:text-primary transition-colors line-clamp-1">
{task.title}
</h3>
<span
className={`text-[10px] px-2 py-0.5 rounded-md font-semibold border ${priorityConfig.className}`}
>
{priorityConfig.label}
</span>
</div>
{task.description && (
<p className="text-xs text-foreground-muted line-clamp-1">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 pt-1 text-xs">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-card/60 text-foreground-muted border border-border/60">
<Calendar size={12} className="text-foreground-muted/70" />
Deleted {deletedDate}
</span>
</div>
</div>
{/* --- Right Side --- */}
<div className="flex sm:flex-col flex-row sm:items-stretch items-center justify-end sm:pt-2 pt-0 sm:border-t border-t-0 border-border/60 gap-2">
<TaskActionButton taskId={task.id} action="delete" />
<TaskActionButton taskId={task.id} action="restore" />
</div>
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,84 @@
/**
* @file trash/components/TaskActionButton.tsx
* @description Client component handling task restoration or permanent deletion requests with loading state and router refresh.
*/
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
/**
* Renders an action button for either restoring or permanently deleting a task,
* managing the request lifecycle and visual transition states.
*
* @param {Object} props - The component props.
* @param {string} props.taskId - The unique identifier of the target task.
* @param {"restore" | "delete"} props.action - The type of action to execute (restore or delete).
* @returns {JSX.Element} The rendered task action button component.
*/
export default function TaskActionButton({
taskId,
action,
}: {
taskId: string;
action: "restore" | "delete";
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const isRestore = action === "restore";
/**
* Executes the API request to either restore or delete the task,
* handling potential errors and refreshing the router upon success.
*/
const handleClick = () => {
startTransition(async () => {
try {
const response = await fetch(
isRestore
? `/api/tasks/${taskId}`
: `/api/tasks/${taskId}?permanent=true`,
{
method: isRestore ? "PATCH" : "DELETE",
headers: isRestore
? { "Content-Type": "application/json" }
: undefined,
body: isRestore ? JSON.stringify({ restore: true }) : undefined,
},
);
const data = await response.json();
if (!response.ok)
throw new Error(data.error || `Failed to ${action} task`);
router.refresh();
} catch (error) {
console.error(`Error during task ${action}:`, error);
}
});
};
const Icon = isRestore ? RotateCcw : Trash2;
const styles = isRestore
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-rose-500/10 text-rose-500 hover:bg-rose-500/20";
return (
<button
onClick={handleClick}
disabled={isPending}
className={`flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer disabled:opacity-50 ${styles}`}
>
{isPending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Icon size={12} />
)}
{isRestore ? "Restore" : "Delete"}
</button>
);
}

View file

@ -0,0 +1,44 @@
/**
* @file trash/components/TrashHeader.tsx
* @description Client component rendering the header section for the trash view, including an icon, title, description, and navigation back to the dashboard.
*/
"use client";
import { ArrowLeft, Trash2 } from "lucide-react";
import Link from "next/link";
/**
* Renders the trash page header featuring title details, an indicator icon,
* and a link to navigate back to the main dashboard.
*
* @returns {JSX.Element} The rendered trash header component.
*/
export default function TrashHeader() {
return (
<>
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-rose-500/10 text-rose-500 rounded-2xl">
<Trash2 size={24} />
</div>
<div>
<h1 className="text-2xl font-bold">Trash</h1>
<p className="text-sm text-foreground-muted">
Tasks removed from your board.
</p>
</div>
</div>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-xs text-primary hover:text-primary-hover transition-colors mb-6 group"
>
<ArrowLeft
size={14}
className="group-hover:-translate-x-1 transition-transform"
/>
Back to Dashboard
</Link>
</>
);
}

41
app/(app)/trash/page.tsx Normal file
View file

@ -0,0 +1,41 @@
/**
* @file trash/page.tsx
* @description Server component rendering the trash management view, fetching soft-deleted tasks belonging to the authenticated user.
*/
import { db } from "@/db";
import { tasksTable } from "@/db/schema";
import { eq, and, isNotNull } from "drizzle-orm";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import TrashList from "./TrashList";
import TrashHeader from "./components/TrashHeader";
/**
* Renders the trash page verifying user authentication, querying soft-deleted tasks,
* and passing them down to the list and header components.
*
* @async
* @returns {Promise<JSX.Element>} The rendered trash page component.
*/
export default async function TrashPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const trashedTasks = await db
.select()
.from(tasksTable)
.where(
and(
eq(tasksTable.userId, session.user.id),
isNotNull(tasksTable.deletedAt),
),
);
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<TrashHeader />
<TrashList tasks={trashedTasks} />
</div>
);
}