V1 Changes

This commit is contained in:
Brian Fertig 2026-08-11 16:37:01 -06:00
parent 0d7336858c
commit 8639f9986c
34 changed files with 1272 additions and 149 deletions

42
app/(app)/admin/page.tsx Normal file
View File

@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { Role } from "@/lib/generated/prisma/enums";
import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table";
export default async function AdminPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role !== Role.ADMIN) redirect("/");
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, name: true, email: true, role: true, createdAt: true },
});
const rows: AdminUserRow[] = users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAtLabel: user.createdAt.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
}));
return (
<div className="flex h-full flex-col gap-4 p-4">
<div>
<h1 className="text-2xl font-bold">Admin</h1>
<p className="text-sm text-muted-foreground">
Everyone with an account on this site. Click a role to change it.
</p>
</div>
<AdminUserTable initialUsers={rows} currentUserId={session.user.id} />
</div>
);
}

View File

@ -13,7 +13,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return ( return (
<SideNavProvider> <SideNavProvider>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<SideNav userEmail={session.user.email ?? ""} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
</div> </div>
</SideNavProvider> </SideNavProvider>

View File

@ -14,6 +14,9 @@ export default async function HomePage() {
orderBy: { order: "asc" }, orderBy: { order: "asc" },
include: { include: {
groups: { groups: {
// Archived groups are kept in the database (not deleted) but
// excluded from the Home board.
where: { archivedAt: null },
orderBy: { order: "asc" }, orderBy: { order: "asc" },
include: { todos: { orderBy: { order: "asc" } } }, include: { todos: { orderBy: { order: "asc" } } },
}, },

12
auth.ts
View File

@ -34,17 +34,25 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const passwordsMatch = await bcrypt.compare(password, user.passwordHash); const passwordsMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordsMatch) return null; if (!passwordsMatch) return null;
return { id: user.id, email: user.email, name: user.name }; return { id: user.id, email: user.email, name: user.name, role: user.role };
}, },
}), }),
], ],
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user?.id) token.sub = user.id; if (user?.id) {
token.sub = user.id;
token.role = user.role;
}
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
if (session.user && token.sub) session.user.id = token.sub; if (session.user && token.sub) session.user.id = token.sub;
// Cached in the JWT at login time -- a role change made through the
// Admin page won't take effect for that user until their next login.
// No session-invalidation mechanism yet; fine for now, revisit if
// role changes need to apply immediately.
if (session.user && token.role) session.user.role = token.role;
return session; return session;
}, },
}, },

View File

@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { KeyRound, Mail, MoreVertical, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { UserRoleMenu } from "@/components/admin/user-role-menu";
import { EditUserEmailDialog } from "@/components/admin/edit-user-email-dialog";
import { ChangePasswordDialog } from "@/components/admin/change-password-dialog";
import { deleteUser } from "@/lib/actions/admin";
import { Role } from "@/lib/generated/prisma/enums";
export interface AdminUserRow {
id: string;
name: string | null;
email: string;
role: Role;
createdAtLabel: string;
}
export function AdminUserTable({
initialUsers,
currentUserId,
}: {
initialUsers: AdminUserRow[];
currentUserId: string;
}) {
const [users, setUsers] = useState(initialUsers);
const [emailDialogFor, setEmailDialogFor] = useState<AdminUserRow | null>(null);
const [passwordDialogFor, setPasswordDialogFor] = useState<AdminUserRow | null>(null);
const [deleteDialogFor, setDeleteDialogFor] = useState<AdminUserRow | null>(null);
async function handleDelete(userId: string) {
const result = await deleteUser(userId);
if (result?.error) {
toast.error(result.error);
return;
}
setUsers((prev) => prev.filter((u) => u.id !== userId));
}
return (
<>
<div className="max-w-3xl overflow-hidden rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Email</th>
<th className="px-4 py-2 font-medium">Role</th>
<th className="px-4 py-2 font-medium">Joined</th>
<th className="px-4 py-2 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((user) => {
const isSelf = user.id === currentUserId;
return (
<tr key={user.id}>
<td className="px-4 py-2">{user.name || "—"}</td>
<td className="px-4 py-2 text-muted-foreground">{user.email}</td>
<td className="px-4 py-2">
<UserRoleMenu userId={user.id} role={user.role} />
</td>
<td className="px-4 py-2 text-muted-foreground">{user.createdAtLabel}</td>
<td className="px-4 py-2 text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={`More options for ${user.email}`}
>
<MoreVertical className="size-4" />
</button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEmailDialogFor(user)}>
<Mail className="size-4" />
Edit email
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setPasswordDialogFor(user)}>
<KeyRound className="size-4" />
Change password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={isSelf}
onClick={() => setDeleteDialogFor(user)}
>
<Trash2 className="size-4" />
Delete account
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{emailDialogFor && (
<EditUserEmailDialog
userId={emailDialogFor.id}
currentEmail={emailDialogFor.email}
open={!!emailDialogFor}
onOpenChange={(open) => !open && setEmailDialogFor(null)}
onSaved={(email) =>
setUsers((prev) => prev.map((u) => (u.id === emailDialogFor.id ? { ...u, email } : u)))
}
/>
)}
{passwordDialogFor && (
<ChangePasswordDialog
userId={passwordDialogFor.id}
userLabel={passwordDialogFor.email}
open={!!passwordDialogFor}
onOpenChange={(open) => !open && setPasswordDialogFor(null)}
/>
)}
{deleteDialogFor && (
<ConfirmDeleteDialog
open={!!deleteDialogFor}
onOpenChange={(open) => !open && setDeleteDialogFor(null)}
title="Delete account?"
description={`Delete ${deleteDialogFor.email}? This permanently deletes their account and all of their categories, groups, and to-dos. This can't be undone.`}
onConfirm={() => handleDelete(deleteDialogFor.id)}
/>
)}
</>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { updateUserPassword } from "@/lib/actions/admin";
export function ChangePasswordDialog({
userId,
userLabel,
open,
onOpenChange,
}: {
userId: string;
userLabel: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | undefined>();
const [pending, setPending] = useState(false);
function handleOpenChange(next: boolean) {
if (next) {
setPassword("");
setConfirm("");
setError(undefined);
}
onOpenChange(next);
}
async function handleSave() {
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (password !== confirm) {
setError("Passwords don't match");
return;
}
setPending(true);
setError(undefined);
const result = await updateUserPassword(userId, password);
setPending(false);
if (result?.error) {
setError(result.error);
return;
}
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>Set a new password for {userLabel}.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor={`new-password-${userId}`}>New password</Label>
<Input
id={`new-password-${userId}`}
type="password"
value={password}
autoFocus
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`confirm-password-${userId}`}>Confirm password</Label>
<Input
id={`confirm-password-${userId}`}
type="password"
value={confirm}
autoComplete="new-password"
onChange={(e) => setConfirm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={pending || !password || !confirm}>
{pending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { updateUserEmail } from "@/lib/actions/admin";
export function EditUserEmailDialog({
userId,
currentEmail,
open,
onOpenChange,
onSaved,
}: {
userId: string;
currentEmail: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (email: string) => void;
}) {
const [email, setEmail] = useState(currentEmail);
const [error, setError] = useState<string | undefined>();
const [pending, setPending] = useState(false);
function handleOpenChange(next: boolean) {
if (next) {
setEmail(currentEmail);
setError(undefined);
}
onOpenChange(next);
}
async function handleSave() {
const trimmed = email.trim();
if (!trimmed) return;
setPending(true);
setError(undefined);
const result = await updateUserEmail(userId, trimmed);
setPending(false);
if (result?.error) {
setError(result.error);
return;
}
onSaved(trimmed);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit email</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`edit-email-${userId}`}>Email</Label>
<Input
id={`edit-email-${userId}`}
type="email"
value={email}
autoFocus
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={pending || !email.trim()}>
{pending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { updateUserRole } from "@/lib/actions/admin";
import { Role } from "@/lib/generated/prisma/enums";
const ROLE_BADGE_VARIANT: Record<Role, "default" | "secondary" | "outline"> = {
ADMIN: "default",
USER: "secondary",
PENDING: "outline",
};
const ROLE_LABEL: Record<Role, string> = {
ADMIN: "Administrator",
USER: "User",
PENDING: "Pending",
};
/** The role badge doubles as the trigger -- clicking it opens a menu to
* change the user's role in place. */
export function UserRoleMenu({ userId, role }: { userId: string; role: Role }) {
const [current, setCurrent] = useState(role);
const [pending, setPending] = useState(false);
async function handleChange(next: string) {
if (next === current || pending) return;
const prev = current;
setCurrent(next as Role);
setPending(true);
const result = await updateUserRole(userId, next);
setPending(false);
if (result?.error) {
setCurrent(prev);
toast.error(result.error);
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Badge
variant={ROLE_BADGE_VARIANT[current]}
className="cursor-pointer select-none"
>
{ROLE_LABEL[current]}
</Badge>
}
/>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup value={current} onValueChange={handleChange}>
{Object.values(Role).map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{ROLE_LABEL[value]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -13,6 +13,7 @@ import {
createGroup, createGroup,
updateGroup as updateGroupAction, updateGroup as updateGroupAction,
deleteGroup as deleteGroupAction, deleteGroup as deleteGroupAction,
archiveGroup as archiveGroupAction,
reorderGroupsInCategory, reorderGroupsInCategory,
moveGroupToCategory, moveGroupToCategory,
} from "@/lib/actions/groups"; } from "@/lib/actions/groups";
@ -36,6 +37,7 @@ interface BoardContextValue {
data: { title?: string; color?: string } data: { title?: string; color?: string }
) => Promise<void>; ) => Promise<void>;
removeGroup: (groupId: string, categoryId: string) => Promise<void>; removeGroup: (groupId: string, categoryId: string) => Promise<void>;
archiveGroup: (groupId: string, categoryId: string) => Promise<void>;
reorderGroups: (categoryId: string, orderedIds: string[]) => Promise<void>; reorderGroups: (categoryId: string, orderedIds: string[]) => Promise<void>;
moveGroup: ( moveGroup: (
groupId: string, groupId: string,
@ -110,7 +112,11 @@ export function BoardProvider({
return prev.filter((c) => c.id !== categoryId); return prev.filter((c) => c.id !== categoryId);
}); });
try { try {
await deleteCategoryAction(categoryId); const result = await deleteCategoryAction(categoryId);
if (result?.error) {
setCategories(prevState);
toast.error(result.error);
}
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't delete category. Try again."); toast.error("Couldn't delete category. Try again.");
@ -171,6 +177,22 @@ export function BoardProvider({
} }
}, []); }, []);
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
return prev.map((c) =>
c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c
);
});
try {
await archiveGroupAction(groupId);
} catch {
setCategories(prevState);
toast.error("Couldn't archive group. Try again.");
}
}, []);
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => { const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
let prevState: CategoryDTO[] = []; let prevState: CategoryDTO[] = [];
setCategories((prev) => { setCategories((prev) => {
@ -339,6 +361,7 @@ export function BoardProvider({
addGroup, addGroup,
editGroup, editGroup,
removeGroup, removeGroup,
archiveGroup,
reorderGroups, reorderGroups,
moveGroup, moveGroup,
saveNote, saveNote,

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core"; import { useDndContext, useDroppable } from "@dnd-kit/core";
@ -15,11 +16,14 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { GroupCard } from "@/components/board/group-card"; import { GroupCard } from "@/components/board/group-card";
import { AddGroupPopover } from "@/components/board/add-group-popover"; import { AddGroupPopover } from "@/components/board/add-group-popover";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import type { CategoryDTO } from "@/types/board"; import type { CategoryDTO } from "@/types/board";
export function CategoryLane({ category }: { category: CategoryDTO }) { export function CategoryLane({ category }: { category: CategoryDTO }) {
const { removeCategory } = useBoard(); const { removeCategory } = useBoard();
const [confirmOpen, setConfirmOpen] = useState(false);
const isEmpty = category.groups.length === 0;
// The lane itself is sortable (so lanes can be reordered), which // The lane itself is sortable (so lanes can be reordered), which
// registers its *entire* rect -- header, cards, empty space, all of it -- // registers its *entire* rect -- header, cards, empty space, all of it --
@ -31,8 +35,15 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
// nothing. Disabling this lane's droppable side while a *group* (not a // nothing. Disabling this lane's droppable side while a *group* (not a
// lane) is being dragged removes it from collision detection entirely, // lane) is being dragged removes it from collision detection entirely,
// leaving only the group cards and the dropzone as valid targets. // leaving only the group cards and the dropzone as valid targets.
const { active } = useDndContext(); const { active, over } = useDndContext();
const isDraggingGroup = active?.data.current?.type === "group"; const isDraggingGroup = active?.data.current?.type === "group";
// While a group is being dragged, light up this lane the moment the
// pointer is over anything inside it -- a card or the empty dropzone
// below them -- so an (especially empty or short) lane still gives clear
// "drop here" feedback even when there's no card directly under the
// cursor to highlight.
const overData = over?.data.current as { categoryId?: string } | undefined;
const isDropTargetLane = isDraggingGroup && overData?.categoryId === category.id;
const { const {
setNodeRef, setNodeRef,
@ -56,6 +67,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
const groupIds = category.groups.map((g) => g.id); const groupIds = category.groups.map((g) => g.id);
return ( return (
<>
<div <div
ref={setNodeRef} ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
@ -89,7 +101,8 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => removeCategory(category.id)} disabled={!isEmpty}
onClick={() => setConfirmOpen(true)}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete category Delete category
@ -98,7 +111,18 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
</DropdownMenu> </DropdownMenu>
</div> </div>
<div ref={setDropzoneRef} className="flex-1 space-y-2 overflow-y-auto px-3 pb-2"> <div
ref={setDropzoneRef}
className={cn(
// pt-2 (rather than relying on the header's own bottom padding)
// leaves enough clearance that a card's hover lift
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
// under the lane header above it.
"flex-1 space-y-2 overflow-y-auto rounded-lg px-3 pt-2 pb-2 transition-colors",
isDropTargetLane &&
"bg-primary/10 outline-2 outline-dashed outline-primary/50 -outline-offset-2"
)}
>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}> <SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{category.groups.map((group) => ( {category.groups.map((group) => (
<GroupCard key={group.id} group={group} /> <GroupCard key={group.id} group={group} />
@ -110,5 +134,14 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
<AddGroupPopover categoryId={category.id} /> <AddGroupPopover categoryId={category.id} />
</div> </div>
</div> </div>
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete category?"
description={`Delete "${category.name}"? This can't be undone.`}
onConfirm={() => removeCategory(category.id)}
/>
</>
); );
} }

View File

@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useBoard } from "@/components/board/board-context";
import type { GroupDTO } from "@/types/board";
const TITLE_MAX = 20;
export function EditGroupDialog({
group,
open,
onOpenChange,
}: {
group: GroupDTO;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { editGroup } = useBoard();
const [title, setTitle] = useState(group.title);
const [pending, setPending] = useState(false);
function handleOpenChange(next: boolean) {
if (next) setTitle(group.title);
onOpenChange(next);
}
async function handleSave() {
const trimmed = title.trim();
if (!trimmed) return;
setPending(true);
await editGroup(group.id, group.categoryId, { title: trimmed });
setPending(false);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename group</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`edit-group-title-${group.id}`}>Title</Label>
<Input
id={`edit-group-title-${group.id}`}
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<p className="text-right text-xs text-muted-foreground">
{title.length}/{TITLE_MAX}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={pending || !title.trim()}>
{pending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -16,14 +16,16 @@ import type { GroupDTO } from "@/types/board";
export function GroupCardOverlay({ group }: { group: GroupDTO }) { export function GroupCardOverlay({ group }: { group: GroupDTO }) {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const color = getGroupColor(group.color); const color = getGroupColor(group.color);
const borderColor = resolvedTheme === "dark" ? color.dark : color.light; const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
return ( return (
<div <div
style={{ borderColor }} style={{ borderColor, backgroundColor }}
className={cn( className={cn("rotate-1 scale-105 cursor-grabbing rounded-xl border-[3px] p-3 shadow-lg")}
"rotate-1 scale-105 cursor-grabbing rounded-xl border-[3px] bg-card p-3 shadow-lg"
)}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3> <h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>

View File

@ -1,17 +1,20 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { MoreVertical, Trash2 } from "lucide-react"; import { Archive, GripVertical, MoreVertical, Pencil, StickyNote, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button";
import { TodoCheckbox } from "@/components/board/todo-checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getGroupColor } from "@/lib/colors"; import { getGroupColor } from "@/lib/colors";
@ -19,12 +22,16 @@ import { useBoard } from "@/components/board/board-context";
import { NotesDialog } from "@/components/board/notes-dialog"; import { NotesDialog } from "@/components/board/notes-dialog";
import { TodoCreatePopover } from "@/components/board/todo-create-popover"; import { TodoCreatePopover } from "@/components/board/todo-create-popover";
import { TodoEditDialog } from "@/components/board/todo-edit-dialog"; import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import type { GroupDTO, TodoDTO } from "@/types/board"; import type { GroupDTO, TodoDTO } from "@/types/board";
export function GroupCard({ group }: { group: GroupDTO }) { export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup } = useBoard(); const { toggleTodoDone, removeGroup, archiveGroup } = useBoard();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null); const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const { const {
attributes, attributes,
@ -34,54 +41,62 @@ export function GroupCard({ group }: { group: GroupDTO }) {
transform, transform,
transition, transition,
isDragging, isDragging,
isOver,
} = useSortable({ } = useSortable({
id: group.id, id: group.id,
data: { type: "group", categoryId: group.categoryId }, data: { type: "group", categoryId: group.categoryId },
}); });
// Another card is being dragged and is currently hovering over this one
// dnd-kit's KeyboardSensor only treats Space/Enter as "pick up this card" // -- signal "it'll land near here" with a ring, distinct from this
// when the key event's target is this specific activator node -- but // card's own accent-colored border. (isDragging guards against this
// that guard is skipped entirely if activatorNode.current is never set, // card ever ringing itself while it's the one being moved.)
// which left it preventDefault()-ing every Space/Enter keydown that const isDropTarget = isOver && !isDragging;
// bubbled up through React's tree (not the DOM tree), including from
// portaled Popover/Dialog content nested inside the card (the notes
// editor, the "add to-do" popover). Wiring the same node as the
// activator restores the target check.
//
// This must be memoized: a fresh function identity on every render makes
// React detach+reattach the ref each time, which during a drag (many
// re-renders as transform/isDragging change) resets dnd-kit's node
// registration and broke drop detection entirely.
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node);
setActivatorNodeRef(node);
},
[setNodeRef, setActivatorNodeRef]
);
const color = getGroupColor(group.color); const color = getGroupColor(group.color);
const borderColor = resolvedTheme === "dark" ? color.dark : color.light; const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
// A subdued tint of the same stroke color, blended into the theme's own
// card surface -- not a fixed pastel, so it automatically stays correct
// if the neutral card token ever changes, and needs no separate
// light/dark background palette to hand-tune.
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
// Only offer archiving once there's actually something done -- a group
// with no to-dos yet, or with any still open, isn't "finished" yet.
const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
return ( return (
<> <>
<div <div
ref={setCardRef} ref={setNodeRef}
{...attributes}
{...listeners}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
borderColor, borderColor,
backgroundColor,
}} }}
className={cn( className={cn(
"cursor-grab touch-none rounded-xl border-[3px] bg-card p-3 shadow-sm transition-shadow", "rounded-xl border-[3px] p-3 shadow-sm transition-shadow",
"hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing", "hover:-translate-y-0.5 hover:shadow-md",
isDragging && "opacity-50 shadow-lg" isDragging && "opacity-50 shadow-lg",
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)} )}
> >
<div className="flex items-start justify-between gap-1"> <div className="flex items-start justify-between gap-1">
<div className="flex min-w-0 flex-1 items-start gap-1">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="mt-1 flex shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground/60 hover:text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to move ${group.title}`}
>
<GripVertical className="size-4" />
</button>
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3> <h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
</div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<NotesDialog group={group} /> <NotesDialog group={group} />
<DropdownMenu> <DropdownMenu>
@ -96,10 +111,12 @@ export function GroupCard({ group }: { group: GroupDTO }) {
} }
/> />
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem onClick={() => setEditOpen(true)}>
variant="destructive" <Pencil className="size-4" />
onClick={() => removeGroup(group.id, group.categoryId)} Edit
> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete group Delete group
</DropdownMenuItem> </DropdownMenuItem>
@ -109,33 +126,60 @@ export function GroupCard({ group }: { group: GroupDTO }) {
</div> </div>
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
{group.todos.map((todo) => ( {group.todos.map((todo) => {
<li key={todo.id} className="flex items-start gap-2 py-0.5"> const todoButton = (
<Checkbox
checked={todo.completed}
onCheckedChange={(checked) =>
toggleTodoDone(todo.id, group.id, group.categoryId, checked === true)
}
className="mt-0.5"
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
/>
<button <button
type="button" type="button"
onClick={() => setEditingTodo(todo)} onClick={() => setEditingTodo(todo)}
className={cn( className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline", "flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
todo.completed && "text-muted-foreground line-through" todo.completed && "text-muted-foreground line-through"
)} )}
> >
{todo.title} <span className="min-w-0 truncate">{todo.title}</span>
{todo.details && (
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
)}
</button> </button>
);
return (
<li key={todo.id} className="flex items-start gap-2 py-0.5">
<TodoCheckbox
checked={todo.completed}
onCheckedChange={(checked) =>
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
}
accentColor={borderColor}
isDark={isDark}
className="mt-0.5"
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
/>
{todo.details ? (
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
) : (
todoButton
)}
</li> </li>
))} );
})}
</ul> </ul>
<div className="mt-1"> <div className="mt-1">
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} /> <TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
</div> </div>
{canArchive && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
>
<Archive className="size-3.5" />
Archive
</Button>
)}
</div> </div>
{editingTodo && ( {editingTodo && (
@ -147,6 +191,16 @@ export function GroupCard({ group }: { group: GroupDTO }) {
onOpenChange={(open) => !open && setEditingTodo(null)} onOpenChange={(open) => !open && setEditingTodo(null)}
/> />
)} )}
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete group?"
description={`Delete "${group.title}" and all of its to-dos? This can't be undone.`}
onConfirm={() => removeGroup(group.id, group.categoryId)}
/>
</> </>
); );
} }

View File

@ -0,0 +1,25 @@
/**
* A deliberately imperfect checkmark: two uneven bezier strokes instead of
* crisp straight lines, so it reads as drawn rather than rendered. The
* upstroke's tip ends above y=0 in the viewBox on purpose -- rendered with
* `overflow-visible` at roughly the checkbox's own size, only that tip
* pokes out the top of the box, while the rest stays flush inside it.
*/
export function HandDrawnCheck({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
className={className}
aria-hidden="true"
>
<path
d="M4 12.3 Q6.3 15 9.5 19 Q15 10.3 21 -3"
stroke="currentColor"
strokeWidth="3.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useTheme } from "next-themes";
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
const OFFSET = 16;
// Rough size assumptions used to keep the tooltip on-screen -- cheaper than
// measuring the rendered node and repositioning after the fact, and close
// enough for a small hover preview.
const ASSUMED_WIDTH = 288;
const ASSUMED_HEIGHT = 180;
/**
* Wraps its child in a tooltip that tracks the cursor instead of anchoring
* to the trigger element -- rendered via a portal to `document.body` so it
* isn't caught by an ancestor's CSS `transform` (dnd-kit positions dragged
* cards with one, which would otherwise turn `position: fixed` here into
* "fixed to that card" instead of the viewport). Content is rendered as
* markdown, same as the editor it was written in, so links etc. actually
* show up as links in the preview too.
*/
export function MouseFollowTooltip({
content,
children,
}: {
content: string;
children: React.ReactNode;
}) {
const { resolvedTheme } = useTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
function handleMove(e: React.MouseEvent) {
setPos({ x: e.clientX, y: e.clientY });
}
let left = 0;
let top = 0;
if (pos) {
left = pos.x + OFFSET;
top = pos.y + OFFSET;
if (typeof window !== "undefined") {
if (left + ASSUMED_WIDTH > window.innerWidth) left = pos.x - ASSUMED_WIDTH - OFFSET;
if (top + ASSUMED_HEIGHT > window.innerHeight) top = pos.y - ASSUMED_HEIGHT - OFFSET;
}
}
return (
<span
className="contents"
onMouseEnter={handleMove}
onMouseMove={handleMove}
onMouseLeave={() => setPos(null)}
>
{children}
{pos &&
typeof document !== "undefined" &&
createPortal(
<div
role="tooltip"
data-color-mode={colorMode}
className="pointer-events-none fixed z-50 max-h-44 w-72 overflow-hidden rounded-md border bg-popover p-2.5 text-xs text-popover-foreground shadow-md"
style={{ left, top }}
>
<MarkdownPreview source={content} />
</div>,
document.body
)}
</span>
);
}

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import dynamic from "next/dynamic";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { NotebookPen, Pencil } from "lucide-react"; import { NotebookPen, Pencil } from "lucide-react";
@ -13,17 +12,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import type { GroupDTO } from "@/types/board"; import type { GroupDTO } from "@/types/board";
// Markdown editing touches the DOM directly and has no server-render value,
// so it's loaded client-side only and kept out of the initial board bundle.
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
const MarkdownPreview = dynamic(
() => import("@uiw/react-md-editor").then((mod) => mod.default.Markdown),
{ ssr: false }
);
export function NotesDialog({ group }: { group: GroupDTO }) { export function NotesDialog({ group }: { group: GroupDTO }) {
const { saveNote } = useBoard(); const { saveNote } = useBoard();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();

View File

@ -0,0 +1,59 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "@/lib/utils";
import { HandDrawnCheck } from "@/components/board/hand-drawn-check";
/**
* The to-do checkbox for a group card: its stroke matches the card's own
* accent color instead of the neutral theme border, and its checkmark is a
* solid, slightly-oversized "hand-drawn" tick (rendered white on dark
* theme, black on light theme) that pokes just above the box.
*/
export function TodoCheckbox({
checked,
onCheckedChange,
accentColor,
isDark,
className,
"aria-label": ariaLabel,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
accentColor: string;
isDark: boolean;
className?: string;
"aria-label": string;
}) {
const checkColor = isDark ? "#ffffff" : "#000000";
return (
<CheckboxPrimitive.Root
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={ariaLabel}
style={{
borderColor: accentColor,
backgroundColor: checked ? accentColor : "transparent",
}}
className={cn(
"relative flex size-4 shrink-0 items-center justify-center overflow-visible rounded-[4px] border-2 outline-none transition-colors",
"focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<CheckboxPrimitive.Indicator
className="pointer-events-none absolute inset-0"
style={{ color: checkColor }}
>
{/* The check's own path dips a couple of viewBox units above y=0,
so only its top tip overhangs the box -- the sides and bottom
stay flush, keeping the effect to "pokes out the top" rather
than the icon just being oversized on every edge. */}
<HandDrawnCheck className="size-full overflow-visible drop-shadow-[0_1px_0.5px_rgba(0,0,0,0.35)]" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}

View File

@ -1,13 +1,14 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { MDEditor } from "@/components/markdown/markdown-widgets";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
const TITLE_MAX = 20; const TITLE_MAX = 20;
@ -20,6 +21,9 @@ export function TodoCreatePopover({
categoryId: string; categoryId: string;
}) { }) {
const { addTodo } = useBoard(); const { addTodo } = useBoard();
const { resolvedTheme } = useTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");
@ -53,7 +57,7 @@ export function TodoCreatePopover({
</Button> </Button>
} }
/> />
<PopoverContent className="w-72" align="start"> <PopoverContent className="w-96" align="start" data-color-mode={colorMode}>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`todo-title-${groupId}`}>Title</Label> <Label htmlFor={`todo-title-${groupId}`}>Title</Label>
@ -71,11 +75,11 @@ export function TodoCreatePopover({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`todo-details-${groupId}`}>Details (optional)</Label> <Label htmlFor={`todo-details-${groupId}`}>Details (optional)</Label>
<Textarea <MDEditor
id={`todo-details-${groupId}`}
value={details} value={details}
onChange={(e) => setDetails(e.target.value)} onChange={(v) => setDetails(v ?? "")}
rows={3} height={160}
textareaProps={{ id: `todo-details-${groupId}` }}
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={pending || !title.trim()}> <Button type="submit" className="w-full" disabled={pending || !title.trim()}>

View File

@ -1,12 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Trash2 } from "lucide-react"; import { useTheme } from "next-themes";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -14,6 +14,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import type { TodoDTO } from "@/types/board"; import type { TodoDTO } from "@/types/board";
@ -33,14 +34,22 @@ export function TodoEditDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { editTodo, removeTodo } = useBoard(); const { editTodo, removeTodo } = useBoard();
const { resolvedTheme } = useTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [title, setTitle] = useState(todo.title); const [title, setTitle] = useState(todo.title);
const [details, setDetails] = useState(todo.details ?? ""); const [details, setDetails] = useState(todo.details ?? "");
// Details gets its own view/edit toggle, same as the group's Notes
// editor -- markdown renders as a preview until you choose to edit it,
// rather than always showing raw source in a plain textarea.
const [editingDetails, setEditingDetails] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
function handleOpenChange(next: boolean) { function handleOpenChange(next: boolean) {
if (next) { if (next) {
setTitle(todo.title); setTitle(todo.title);
setDetails(todo.details ?? ""); setDetails(todo.details ?? "");
setEditingDetails(false);
} }
onOpenChange(next); onOpenChange(next);
} }
@ -66,7 +75,7 @@ export function TodoEditDialog({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit to-do</DialogTitle> <DialogTitle>Edit to-do</DialogTitle>
</DialogHeader> </DialogHeader>
@ -83,14 +92,37 @@ export function TodoEditDialog({
{title.length}/{TITLE_MAX} {title.length}/{TITLE_MAX}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`edit-details-${todo.id}`}>Details</Label> <div className="flex items-center justify-between">
<Textarea <Label>Details</Label>
id={`edit-details-${todo.id}`} <Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => setEditingDetails((v) => !v)}
>
<Pencil className="size-3" />
{editingDetails ? "Preview" : "Edit"}
</Button>
</div>
{editingDetails ? (
<MDEditor
value={details} value={details}
onChange={(e) => setDetails(e.target.value)} onChange={(v) => setDetails(v ?? "")}
rows={4} height={220}
/> />
) : details ? (
<div className="max-h-48 overflow-y-auto rounded-md border p-3">
<MarkdownPreview source={details} />
</div>
) : (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No details yet. Click Edit to add some -- links, lists, etc.
</p>
)}
</div> </div>
</div> </div>
<DialogFooter className="sm:justify-between"> <DialogFooter className="sm:justify-between">

View File

@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function ConfirmDeleteDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Delete",
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
onConfirm: () => void | Promise<void>;
}) {
const [pending, setPending] = useState(false);
async function handleConfirm() {
setPending(true);
await onConfirm();
setPending(false);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(next) => !pending && onOpenChange(next)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={pending}>
{pending ? "Deleting…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import type { RehypeRewriteOptions } from "rehype-rewrite";
// Markdown editing/rendering touches the DOM directly and has no
// server-render value, so both pieces are loaded client-side only and kept
// out of the initial board bundle. Shared here so every place that needs
// markdown (group notes, to-do details) uses the exact same editor/preview
// widgets instead of each declaring its own dynamic import.
export const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
const RawMarkdownPreview = dynamic(
() => import("@uiw/react-md-editor").then((mod) => mod.default.Markdown),
{ ssr: false }
);
// Rewrites every rendered <a> to open in a new tab -- clicking a link in a
// note/to-do preview would otherwise navigate the whole app away from the
// board. `noopener noreferrer` keeps the new tab from being able to reach
// back into this one via `window.opener`.
const openLinksInNewTab: RehypeRewriteOptions["rewrite"] = (node) => {
if (node.type === "element" && node.tagName === "a") {
node.properties = {
...node.properties,
target: "_blank",
rel: ["noopener", "noreferrer"],
};
}
};
export function MarkdownPreview(props: ComponentProps<typeof RawMarkdownPreview>) {
return <RawMarkdownPreview {...props} rehypeRewrite={openLinksInNewTab} />;
}

View File

@ -1,4 +1,4 @@
import { Home } from "lucide-react"; import { Home, ShieldUser } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
export interface NavItem { export interface NavItem {
@ -10,3 +10,7 @@ export interface NavItem {
// Scaffolded with just "Home" per the spec -- trivially extended by pushing // Scaffolded with just "Home" per the spec -- trivially extended by pushing
// more entries here later. // more entries here later.
export const NAV_ITEMS: NavItem[] = [{ href: "/", label: "Home", icon: Home }]; export const NAV_ITEMS: NavItem[] = [{ href: "/", label: "Home", icon: Home }];
// Only shown to admins -- appended conditionally by SideNav, not part of
// the always-visible list above.
export const ADMIN_NAV_ITEM: NavItem = { href: "/admin", label: "Admin", icon: ShieldUser };

View File

@ -8,12 +8,14 @@ import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideNav } from "@/components/nav/side-nav-provider"; import { useSideNav } from "@/components/nav/side-nav-provider";
import { SideNavItem } from "@/components/nav/side-nav-item"; import { SideNavItem } from "@/components/nav/side-nav-item";
import { NAV_ITEMS } from "@/components/nav/nav-items"; import { NAV_ITEMS, ADMIN_NAV_ITEM } from "@/components/nav/nav-items";
import { ThemeToggle } from "@/components/theme/theme-toggle"; import { ThemeToggle } from "@/components/theme/theme-toggle";
import { logout } from "@/lib/actions/auth"; import { logout } from "@/lib/actions/auth";
import { Role } from "@/lib/generated/prisma/enums";
export function SideNav({ userEmail }: { userEmail: string }) { export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
const { collapsed, toggle } = useSideNav(); const { collapsed, toggle } = useSideNav();
const items = role === Role.ADMIN ? [...NAV_ITEMS, ADMIN_NAV_ITEM] : NAV_ITEMS;
return ( return (
<aside <aside
@ -30,7 +32,7 @@ export function SideNav({ userEmail }: { userEmail: string }) {
<Separator /> <Separator />
<nav className="flex flex-1 flex-col gap-1 p-2"> <nav className="flex flex-1 flex-col gap-1 p-2">
{NAV_ITEMS.map((item) => ( {items.map((item) => (
<SideNavItem key={item.href} item={item} collapsed={collapsed} /> <SideNavItem key={item.href} item={item} collapsed={collapsed} />
))} ))}
</nav> </nav>

90
lib/actions/admin.ts Normal file
View File

@ -0,0 +1,90 @@
"use server";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
import { Role } from "@/lib/generated/prisma/enums";
import { EmailSchema, PasswordSchema } from "@/lib/validation/auth";
const ROLE_VALUES: readonly string[] = Object.values(Role);
// All of these are modeled as return values rather than thrown errors --
// Next.js redacts thrown Server Action error messages in production, and
// these are all expected, user-facing validation outcomes (bad input,
// "last admin" guard, etc.), not exceptional failures.
export async function updateUserRole(
userId: string,
role: string
): Promise<{ error?: string }> {
const admin = await requireAdmin();
if (!ROLE_VALUES.includes(role)) return { error: "Invalid role." };
// The acting admin is always at least one admin; the only way this
// action could zero out admins is by demoting *themselves* while
// they're the last one.
if (userId === admin.id && role !== Role.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: Role.ADMIN } });
if (adminCount <= 1) {
return { error: "You can't remove the last administrator." };
}
}
const target = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!target) return { error: "User not found." };
await prisma.user.update({ where: { id: userId }, data: { role: role as Role } });
revalidatePath("/admin");
return {};
}
export async function updateUserEmail(
userId: string,
email: string
): Promise<{ error?: string }> {
await requireAdmin();
const parsed = EmailSchema.safeParse(email);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Invalid email." };
const existing = await prisma.user.findUnique({ where: { email: parsed.data } });
if (existing && existing.id !== userId) {
return { error: "Another account already uses that email." };
}
await prisma.user.update({ where: { id: userId }, data: { email: parsed.data } });
revalidatePath("/admin");
return {};
}
export async function updateUserPassword(
userId: string,
password: string
): Promise<{ error?: string }> {
await requireAdmin();
const parsed = PasswordSchema.safeParse(password);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Invalid password." };
const passwordHash = await bcrypt.hash(parsed.data, 12);
await prisma.user.update({ where: { id: userId }, data: { passwordHash } });
return {};
}
/**
* Deletes a user's account entirely, cascading through their categories,
* groups, and to-dos (`onDelete: Cascade` all the way down the schema).
*/
export async function deleteUser(userId: string): Promise<{ error?: string }> {
const admin = await requireAdmin();
if (userId === admin.id) {
return { error: "You can't delete your own account." };
}
const target = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!target) return { error: "User not found." };
await prisma.user.delete({ where: { id: userId } });
revalidatePath("/admin");
return {};
}

View File

@ -7,6 +7,7 @@ import { AuthError } from "next-auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signIn, signOut } from "@/auth"; import { signIn, signOut } from "@/auth";
import { LoginSchema, SignupSchema } from "@/lib/validation/auth"; import { LoginSchema, SignupSchema } from "@/lib/validation/auth";
import { Role } from "@/lib/generated/prisma/enums";
export type SignupState = { export type SignupState = {
error?: string; error?: string;
@ -65,7 +66,12 @@ export async function signup(
} }
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({ data: { name, email, passwordHash } }); // The very first account on the whole site becomes the administrator;
// everyone after that gets the default "USER" role.
const isFirstUser = (await prisma.user.count()) === 0;
await prisma.user.create({
data: { name, email, passwordHash, role: isFirstUser ? Role.ADMIN : Role.USER },
});
try { try {
// Signs the user in and redirects to "/" on success. NextAuth throws a // Signs the user in and redirects to "/" on success. NextAuth throws a

View File

@ -34,15 +34,32 @@ export async function renameCategory(categoryId: string, name: string): Promise<
revalidatePath("/"); revalidatePath("/");
} }
export async function deleteCategory(categoryId: string): Promise<void> { export type DeleteCategoryResult = { error?: string };
/**
* Deleting a non-empty category would cascade-delete every group (and
* their to-dos) inside it -- so this is an expected, user-facing
* validation rather than an exceptional failure, and is modeled as a
* return value rather than a thrown error (Next.js redacts thrown Server
* Action error messages in production, so a specific message needs to
* come back this way to actually reach the client).
*/
export async function deleteCategory(categoryId: string): Promise<DeleteCategoryResult> {
const userId = await requireUserId(); const userId = await requireUserId();
const { count } = await prisma.category.deleteMany({ const category = await prisma.category.findFirst({
where: { id: categoryId, userId }, where: { id: categoryId, userId },
select: { _count: { select: { groups: true } } },
}); });
if (count === 0) throw new Error("Category not found"); if (!category) return { error: "Category not found." };
if (category._count.groups > 0) {
return { error: "This category still has groups in it. Delete or move them out first." };
}
await prisma.category.delete({ where: { id: categoryId } });
revalidatePath("/"); revalidatePath("/");
return {};
} }
/** /**

View File

@ -70,6 +70,22 @@ export async function deleteGroup(groupId: string): Promise<void> {
revalidatePath("/"); revalidatePath("/");
} }
/**
* Archives a group instead of deleting it -- the row (and its to-dos) is
* kept, just excluded from the Home board query going forward.
*/
export async function archiveGroup(groupId: string): Promise<void> {
const userId = await requireUserId();
const { count } = await prisma.group.updateMany({
where: { id: groupId, category: { userId } },
data: { archivedAt: new Date() },
});
if (count === 0) throw new Error("Group not found");
revalidatePath("/");
}
/** Persists a new card order within a single lane. */ /** Persists a new card order within a single lane. */
export async function reorderGroupsInCategory( export async function reorderGroupsInCategory(
categoryId: string, categoryId: string,

View File

@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Role } from "@/lib/generated/prisma/enums";
/** Returns the current session's user id, or throws if unauthenticated. */ /** Returns the current session's user id, or throws if unauthenticated. */
export async function requireUserId(): Promise<string> { export async function requireUserId(): Promise<string> {
@ -10,3 +11,11 @@ export async function requireUserId(): Promise<string> {
} }
return session.user.id; return session.user.id;
} }
/** Returns the current session's user, or throws if not a signed-in admin. */
export async function requireAdmin() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
if (session.user.role !== Role.ADMIN) throw new Error("Forbidden");
return session.user;
}

View File

@ -1,12 +1,15 @@
import { z } from "zod"; import { z } from "zod";
export const EmailSchema = z.string().trim().toLowerCase().email("Enter a valid email address");
export const PasswordSchema = z.string().min(8, "Password must be at least 8 characters");
export const SignupSchema = z.object({ export const SignupSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(60), name: z.string().trim().min(1, "Name is required").max(60),
email: z.string().trim().toLowerCase().email("Enter a valid email address"), email: EmailSchema,
password: z.string().min(8, "Password must be at least 8 characters"), password: PasswordSchema,
}); });
export const LoginSchema = z.object({ export const LoginSchema = z.object({
email: z.string().trim().toLowerCase().email("Enter a valid email address"), email: EmailSchema,
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
}); });

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "archivedAt" TIMESTAMP(3);

View File

@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER', 'PENDING');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER';
-- Backfill: on installs that already have users, the earliest-created
-- account becomes ADMIN -- consistent with "the first person to sign up
-- is the administrator" for accounts that predate this migration.
UPDATE "User" SET "role" = 'ADMIN'
WHERE "id" = (SELECT "id" FROM "User" ORDER BY "createdAt" ASC LIMIT 1);

View File

@ -10,11 +10,21 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
enum Role {
ADMIN
USER
PENDING
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
passwordHash String passwordHash String
name String? name String?
// The very first person to sign up becomes ADMIN; everyone after that
// defaults to USER. PENDING exists as a role an admin can move someone
// into/out of later -- nothing assigns it automatically yet.
role Role @default(USER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -48,6 +58,9 @@ model Group {
// Single evolving markdown document for the group. Not a separate model: // Single evolving markdown document for the group. Not a separate model:
// it's a 1:1, always-exists field with no independent lifecycle. // it's a 1:1, always-exists field with no independent lifecycle.
noteContent String @default("") @db.Text noteContent String @default("") @db.Text
// Set once a group is archived; archived groups are kept (not deleted)
// but excluded from the Home board query. Null = active.
archivedAt DateTime?
categoryId String categoryId String

View File

@ -1,4 +1,5 @@
import { PrismaClient } from "../lib/generated/prisma/client"; import { PrismaClient } from "../lib/generated/prisma/client";
import { Role } from "../lib/generated/prisma/enums";
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { GROUP_COLORS } from "../lib/colors"; import { GROUP_COLORS } from "../lib/colors";
@ -16,6 +17,7 @@ async function main() {
email: "dev@example.com", email: "dev@example.com",
passwordHash, passwordHash,
name: "Dev User", name: "Dev User",
role: Role.ADMIN,
}, },
}); });

18
types/next-auth.d.ts vendored
View File

@ -1,9 +1,25 @@
import type { DefaultSession } from "next-auth"; import type { DefaultSession, DefaultUser } from "next-auth";
import type { Role } from "@/lib/generated/prisma/enums";
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
user: { user: {
id: string; id: string;
role: Role;
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
interface User extends DefaultUser {
role: Role;
}
}
// "next-auth/jwt" just re-exports from "@auth/core/jwt" (`export * from
// ...`), so augmenting "next-auth/jwt" directly wouldn't merge with the
// interface Auth.js's own callbacks actually use -- augment the real
// source module instead.
declare module "@auth/core/jwt" {
interface JWT {
role?: Role;
}
} }