feat(core): full database soft-delete, live sidebar counts and robust subtask undo/restore across all modes
Build and Push Docker Image / build-and-push (push) Successful in 10m12s
Build and Push Docker Image / build-and-push (push) Successful in 10m12s
This commit is contained in:
+1
-3
@@ -39,9 +39,7 @@
|
|||||||
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||||
|
|
||||||
- **Next Steps (Todo):**
|
- **Next Steps (Todo):**
|
||||||
- Fix sidebar list item counts calculation in demo & API modes.
|
- Continuous refinement of user experience based on feedback.
|
||||||
- Completely isolate Trash view and Tag view from My Tasks list state in TaskList.tsx.
|
|
||||||
- Fix Restore and Permanent Delete in Trash view for both Demo and API modes.
|
|
||||||
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
|
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
|
||||||
- Add optional automatic synchronization background polling / webhook integration if requested.
|
- Add optional automatic synchronization background polling / webhook integration if requested.
|
||||||
- Plan next feature iterations or custom integrations as requested by user.
|
- Plan next feature iterations or custom integrations as requested by user.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_isDeleted_idx" ON "Task"("isDeleted");
|
||||||
@@ -63,6 +63,9 @@ model Task {
|
|||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ model Task {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([listId])
|
@@index([listId])
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
|
@@index([isDeleted])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET() {
|
|||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
|
_count: { select: { tasks: { where: { isDeleted: false, completed: false, parentId: null } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(lists);
|
return NextResponse.json(lists);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
tasks: {
|
||||||
|
where: {
|
||||||
|
task: {
|
||||||
|
isDeleted: false,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(tags);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tags:GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch tags" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body || !body.name?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Tag name required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = body.name.trim();
|
||||||
|
const color = body.color || "#4B7BF5";
|
||||||
|
|
||||||
|
const tag = await prisma.tag.upsert({
|
||||||
|
where: {
|
||||||
|
userId_name: {
|
||||||
|
userId: session.user.id,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { color },
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(tag, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tags:POST]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to create/update tag" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
|||||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
// Sanitize allowed fields
|
// Sanitize allowed fields
|
||||||
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"];
|
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId", "isDeleted", "deletedAt"];
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (key in body) data[key] = body[key];
|
if (key in body) data[key] = body[key];
|
||||||
@@ -71,9 +71,25 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
|||||||
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
|
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If restoring a child task (isDeleted: false), also restore ancestor parents
|
||||||
|
if (data.isDeleted === false) {
|
||||||
|
let currentParentId = task.parentId;
|
||||||
|
while (currentParentId) {
|
||||||
|
const parentTask: any = await prisma.task.findUnique({ where: { id: currentParentId } });
|
||||||
|
if (!parentTask) break;
|
||||||
|
if (parentTask.isDeleted) {
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: currentParentId },
|
||||||
|
data: { isDeleted: false, deletedAt: null } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentParentId = parentTask.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.task.update({
|
const updated = await prisma.task.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: data as any,
|
||||||
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||||
});
|
});
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
@@ -83,7 +99,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
export async function DELETE(req: NextRequest, { params }: Ctx) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -92,10 +108,37 @@ export async function DELETE(_: NextRequest, { params }: Ctx) {
|
|||||||
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
await prisma.task.delete({ where: { id } });
|
const { searchParams } = new URL(req.url);
|
||||||
return NextResponse.json({ ok: true });
|
const permanent = searchParams.get("permanent") === "true";
|
||||||
|
|
||||||
|
if (permanent) {
|
||||||
|
await prisma.task.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete task and its children
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isDeleted: true, deletedAt: now } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursively soft-delete children
|
||||||
|
const softDeleteChildren = async (parentId: string) => {
|
||||||
|
const children = await prisma.task.findMany({ where: { parentId } });
|
||||||
|
for (const child of children) {
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: child.id },
|
||||||
|
data: { isDeleted: true, deletedAt: now } as any,
|
||||||
|
});
|
||||||
|
await softDeleteChildren(child.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await softDeleteChildren(id);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, softDeleted: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[tasks/id:DELETE]", err);
|
console.error("[tasks/id:DELETE]", err);
|
||||||
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,52 @@ export async function GET(req: NextRequest) {
|
|||||||
const listId = searchParams.get("listId");
|
const listId = searchParams.get("listId");
|
||||||
const showCompleted = searchParams.get("showCompleted") === "true";
|
const showCompleted = searchParams.get("showCompleted") === "true";
|
||||||
const tagName = searchParams.get("tag");
|
const tagName = searchParams.get("tag");
|
||||||
|
const isTrash = searchParams.get("isTrash") === "true";
|
||||||
|
const countTrash = searchParams.get("countTrash") === "true";
|
||||||
|
|
||||||
|
if (countTrash) {
|
||||||
|
const trashCount = await prisma.task.count({
|
||||||
|
where: { userId: session.user.id, isDeleted: true } as any,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ count: trashCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTrash) {
|
||||||
|
// Return all deleted tasks (top-level or subtasks)
|
||||||
|
const trashTasks = await prisma.task.findMany({
|
||||||
|
where: { userId: session.user.id, isDeleted: true } as any,
|
||||||
|
orderBy: [{ deletedAt: "desc" } as any, { createdAt: "desc" }],
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }],
|
||||||
|
include: { tags: { include: { tag: true } } },
|
||||||
|
},
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(trashTasks);
|
||||||
|
}
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
isDeleted: false,
|
||||||
...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }),
|
...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }),
|
||||||
...(showCompleted ? {} : { completed: false }),
|
...(showCompleted ? {} : { completed: false }),
|
||||||
...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}),
|
...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const tasks = await prisma.task.findMany({
|
||||||
where,
|
where: where as any,
|
||||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
include: {
|
include: {
|
||||||
children: { orderBy: [{ sortOrder: "asc" }] },
|
children: {
|
||||||
|
where: { isDeleted: false } as any,
|
||||||
|
orderBy: [{ sortOrder: "asc" }],
|
||||||
|
include: {
|
||||||
|
children: { where: { isDeleted: false } as any, orderBy: [{ sortOrder: "asc" }] },
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -35,6 +68,29 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const trash = searchParams.get("trash") === "true";
|
||||||
|
|
||||||
|
if (trash) {
|
||||||
|
// Empty all trash permanently
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: { userId: session.user.id, isDeleted: true } as any,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Invalid delete request" }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks:DELETE_BATCH]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to empty trash" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
|
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [apiTrashCount, setApiTrashCount] = useState(0);
|
||||||
|
|
||||||
// Task filter & reconstruct hierarchical structure for Demo and API modes
|
// Task filter & reconstruct hierarchical structure for Demo and API modes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
@@ -136,8 +138,17 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Authenticated API mode
|
// Authenticated API mode
|
||||||
|
// Also fetch trash count for sidebar
|
||||||
|
fetch("/api/tasks?countTrash=true")
|
||||||
|
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||||
|
.then((data) => setApiTrashCount(data.count || 0))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
if (isTrashActive) {
|
if (isTrashActive) {
|
||||||
setTasks([]);
|
fetch("/api/tasks?isTrash=true")
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||||
|
.catch((err) => console.error("Failed to load trash tasks", err));
|
||||||
} else if (selectedTag) {
|
} else if (selectedTag) {
|
||||||
fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`)
|
fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`)
|
||||||
.then((r) => (r.ok ? r.json() : []))
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
@@ -195,7 +206,10 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
setTasks(getAllTrashTasks(store.tasks) as Task[]);
|
setTasks(getAllTrashTasks(store.tasks) as Task[]);
|
||||||
} else {
|
} else {
|
||||||
setTasks([]);
|
fetch("/api/tasks?isTrash=true")
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||||
|
.catch((err) => console.error("Failed to load trash tasks", err));
|
||||||
}
|
}
|
||||||
}, [isDemo]);
|
}, [isDemo]);
|
||||||
|
|
||||||
@@ -357,7 +371,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
}, 6000);
|
}, 6000);
|
||||||
}, [isDemo, selectedTask]);
|
}, [isDemo, selectedTask]);
|
||||||
|
|
||||||
// Undo delete
|
// Undo delete (Restores exact soft-deleted task hierarchy)
|
||||||
const handleUndoDelete = useCallback(async () => {
|
const handleUndoDelete = useCallback(async () => {
|
||||||
if (!undoToast) return;
|
if (!undoToast) return;
|
||||||
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||||
@@ -377,28 +391,16 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
refresh();
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/tasks", {
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||||
method: "POST",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ isDeleted: false, deletedAt: null }),
|
||||||
title: task.title,
|
|
||||||
listId: task.listId,
|
|
||||||
parentId: task.parentId,
|
|
||||||
note: task.note,
|
|
||||||
completed: task.completed,
|
|
||||||
dueDate: task.dueDate,
|
|
||||||
priority: task.priority,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const created = await res.json();
|
|
||||||
if (created.listId === selectedListId) {
|
|
||||||
setTasks((prev) => [...prev, created]);
|
|
||||||
}
|
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to restore task", err);
|
console.error("Failed to restore task via Undo", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [undoToast, isDemo, selectedListId, refresh]);
|
}, [undoToast, isDemo, selectedListId, refresh]);
|
||||||
@@ -418,29 +420,47 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Restore from Trash (Restores task and all its nested subtasks)
|
// Restore from Trash (Restores task and all its nested subtasks)
|
||||||
const handleRestoreTask = (id: string) => {
|
const handleRestoreTask = async (id: string) => {
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = restoreTaskInTree(store.tasks, id);
|
const newTasks = restoreTaskInTree(store.tasks, id);
|
||||||
saveDemoStore(store.lists, newTasks);
|
saveDemoStore(store.lists, newTasks);
|
||||||
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
||||||
refresh();
|
refresh();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isDeleted: false, deletedAt: null }),
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to restore task", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Permanent Delete
|
// Permanent Delete
|
||||||
const handlePermanentDeleteTask = (id: string) => {
|
const handlePermanentDeleteTask = async (id: string) => {
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = deleteTaskInTree(store.tasks, id);
|
const newTasks = deleteTaskInTree(store.tasks, id);
|
||||||
saveDemoStore(store.lists, newTasks);
|
saveDemoStore(store.lists, newTasks);
|
||||||
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
||||||
refresh();
|
refresh();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${id}?permanent=true`, { method: "DELETE" });
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to permanently delete task", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Empty Trash
|
// Empty Trash
|
||||||
const handleEmptyTrash = () => {
|
const handleEmptyTrash = async () => {
|
||||||
if (!confirm("Permanently empty all items in trash?")) return;
|
if (!confirm("Permanently empty all items in trash?")) return;
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
@@ -448,6 +468,13 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
saveDemoStore(store.lists, newTasks);
|
saveDemoStore(store.lists, newTasks);
|
||||||
setTasks([]);
|
setTasks([]);
|
||||||
refresh();
|
refresh();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await fetch("/api/tasks?trash=true", { method: "DELETE" });
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to empty trash", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -514,7 +541,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
isTrashActive={isTrashActive}
|
isTrashActive={isTrashActive}
|
||||||
onTrashSelect={handleTrashSelect}
|
onTrashSelect={handleTrashSelect}
|
||||||
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : undefined}
|
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : apiTrashCount}
|
||||||
mobileOpen={sidebarOpen}
|
mobileOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore, getTagTaskCount
|
|||||||
|
|
||||||
interface User { id: string; name?: string | null; email?: string | null }
|
interface User { id: string; name?: string | null; email?: string | null }
|
||||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||||
|
interface APITag { id: string; name: string; color: string; _count?: { tasks: number } }
|
||||||
|
|
||||||
const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
|
const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
|
||||||
|
|
||||||
@@ -74,14 +75,8 @@ export function Sidebar({
|
|||||||
const [exportListId, setExportListId] = useState<string>("all");
|
const [exportListId, setExportListId] = useState<string>("all");
|
||||||
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
const [tags, setTags] = useState<Array<MockTag | APITag>>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||||
const [internalTrashCount, setInternalTrashCount] = useState(() => {
|
const [internalTrashCount, setInternalTrashCount] = useState(0);
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const store = getDemoStore();
|
|
||||||
return getAllTrashTasks(store.tasks).length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
||||||
|
|
||||||
@@ -98,17 +93,16 @@ export function Sidebar({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTags(getCustomTags());
|
|
||||||
const store = getDemoStore();
|
|
||||||
const trashed = getAllTrashTasks(store.tasks);
|
|
||||||
setInternalTrashCount(trashed.length);
|
|
||||||
}, [lists]);
|
|
||||||
|
|
||||||
const initialListSelectedRef = useRef(false);
|
const initialListSelectedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDemo) {
|
if (isDemo) {
|
||||||
|
setTags(getCustomTags());
|
||||||
|
const store = getDemoStore();
|
||||||
|
const trashed = getAllTrashTasks(store.tasks);
|
||||||
|
setInternalTrashCount(trashed.length);
|
||||||
|
} else {
|
||||||
|
// Load lists
|
||||||
fetch("/api/lists")
|
fetch("/api/lists")
|
||||||
.then((r) => (r.ok ? r.json() : []))
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -121,8 +115,22 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to load lists", err));
|
.catch((err) => console.error("Failed to load lists", err));
|
||||||
|
|
||||||
|
// Load tags
|
||||||
|
fetch("/api/tags")
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) setTags(data);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Failed to load tags", err));
|
||||||
|
|
||||||
|
// Load trash count
|
||||||
|
fetch("/api/tasks?countTrash=true")
|
||||||
|
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||||
|
.then((data) => setInternalTrashCount(data.count || 0))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists]);
|
}, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists, lists.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
@@ -581,7 +589,7 @@ export function Sidebar({
|
|||||||
const active = filterActiveTree(store.tasks as MockTask[], false).filter((t) => t.listId === list.id);
|
const active = filterActiveTree(store.tasks as MockTask[], false).filter((t) => t.listId === list.id);
|
||||||
return active.length > 0 ? active.length : "";
|
return active.length > 0 ? active.length : "";
|
||||||
})()
|
})()
|
||||||
: list._count?.tasks || ""}
|
: (list._count?.tasks ? list._count.tasks : "")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
@@ -610,7 +618,7 @@ export function Sidebar({
|
|||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const tagCount = isDemo && typeof window !== "undefined"
|
const tagCount = isDemo && typeof window !== "undefined"
|
||||||
? getTagTaskCount(getDemoStore().tasks, tag.name)
|
? getTagTaskCount(getDemoStore().tasks, tag.name)
|
||||||
: 0;
|
: ((tag as APITag)._count?.tasks ?? 0);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
@@ -641,14 +649,9 @@ export function Sidebar({
|
|||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="item-label">{t("trash") || "Trash"}</span>
|
<span className="item-label">{t("trash") || "Trash"}</span>
|
||||||
{(() => {
|
{trashCount > 0 ? (
|
||||||
const count = isDemo && typeof window !== "undefined"
|
<span className="item-count" style={{ color: "var(--danger)", fontWeight: 700 }}>{trashCount}</span>
|
||||||
? getAllTrashTasks(getDemoStore().tasks).length
|
) : null}
|
||||||
: (externalTrashCount !== undefined ? externalTrashCount : internalTrashCount);
|
|
||||||
return count > 0 ? (
|
|
||||||
<span className="item-count" style={{ color: "var(--danger)", fontWeight: 700 }}>{count}</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user