From 8639f9986c1695bdfcfe7092939718a9a00b6c97 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 11 Aug 2026 16:37:01 -0600 Subject: [PATCH] V1 Changes --- app/(app)/admin/page.tsx | 42 +++++ app/(app)/layout.tsx | 2 +- app/(app)/page.tsx | 3 + auth.ts | 12 +- components/admin/admin-user-table.tsx | 148 +++++++++++++++++ components/admin/change-password-dialog.tsx | 106 ++++++++++++ components/admin/edit-user-email-dialog.tsx | 86 ++++++++++ components/admin/user-role-menu.tsx | 71 ++++++++ components/board/board-context.tsx | 25 ++- components/board/category-lane.tsx | 137 +++++++++------ components/board/edit-group-dialog.tsx | 78 +++++++++ components/board/group-card-overlay.tsx | 12 +- components/board/group-card.tsx | 156 ++++++++++++------ components/board/hand-drawn-check.tsx | 25 +++ components/board/mouse-follow-tooltip.tsx | 74 +++++++++ components/board/notes-dialog.tsx | 10 +- components/board/todo-checkbox.tsx | 59 +++++++ components/board/todo-create-popover.tsx | 16 +- components/board/todo-edit-dialog.tsx | 52 ++++-- components/confirm-delete-dialog.tsx | 57 +++++++ components/markdown/markdown-widgets.tsx | 35 ++++ components/nav/nav-items.ts | 6 +- components/nav/side-nav.tsx | 8 +- lib/actions/admin.ts | 90 ++++++++++ lib/actions/auth.ts | 8 +- lib/actions/categories.ts | 23 ++- lib/actions/groups.ts | 16 ++ lib/auth-helpers.ts | 9 + lib/validation/auth.ts | 9 +- .../migration.sql | 2 + .../migration.sql | 11 ++ prisma/schema.prisma | 13 ++ prisma/seed.ts | 2 + types/next-auth.d.ts | 18 +- 34 files changed, 1272 insertions(+), 149 deletions(-) create mode 100644 app/(app)/admin/page.tsx create mode 100644 components/admin/admin-user-table.tsx create mode 100644 components/admin/change-password-dialog.tsx create mode 100644 components/admin/edit-user-email-dialog.tsx create mode 100644 components/admin/user-role-menu.tsx create mode 100644 components/board/edit-group-dialog.tsx create mode 100644 components/board/hand-drawn-check.tsx create mode 100644 components/board/mouse-follow-tooltip.tsx create mode 100644 components/board/todo-checkbox.tsx create mode 100644 components/confirm-delete-dialog.tsx create mode 100644 components/markdown/markdown-widgets.tsx create mode 100644 lib/actions/admin.ts create mode 100644 prisma/migrations/20260811190000_add_group_archived_at/migration.sql create mode 100644 prisma/migrations/20260811200000_add_user_role/migration.sql diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 0000000..b2f653d --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -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 ( +
+
+

Admin

+

+ Everyone with an account on this site. Click a role to change it. +

+
+ + +
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index c50135c..8f8d960 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -13,7 +13,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod return (
- +
{children}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index e3fcd56..d074dac 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -14,6 +14,9 @@ export default async function HomePage() { orderBy: { order: "asc" }, include: { groups: { + // Archived groups are kept in the database (not deleted) but + // excluded from the Home board. + where: { archivedAt: null }, orderBy: { order: "asc" }, include: { todos: { orderBy: { order: "asc" } } }, }, diff --git a/auth.ts b/auth.ts index fca18b6..46b01b9 100644 --- a/auth.ts +++ b/auth.ts @@ -34,17 +34,25 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const passwordsMatch = await bcrypt.compare(password, user.passwordHash); 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: { async jwt({ token, user }) { - if (user?.id) token.sub = user.id; + if (user?.id) { + token.sub = user.id; + token.role = user.role; + } return token; }, async session({ session, token }) { 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; }, }, diff --git a/components/admin/admin-user-table.tsx b/components/admin/admin-user-table.tsx new file mode 100644 index 0000000..8e8a8d5 --- /dev/null +++ b/components/admin/admin-user-table.tsx @@ -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(null); + const [passwordDialogFor, setPasswordDialogFor] = useState(null); + const [deleteDialogFor, setDeleteDialogFor] = useState(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 ( + <> +
+ + + + + + + + + + + + {users.map((user) => { + const isSelf = user.id === currentUserId; + return ( + + + + + + + + ); + })} + +
NameEmailRoleJoined + Actions +
{user.name || "—"}{user.email} + + {user.createdAtLabel} + + + + + } + /> + + setEmailDialogFor(user)}> + + Edit email + + setPasswordDialogFor(user)}> + + Change password + + + setDeleteDialogFor(user)} + > + + Delete account + + + +
+
+ + {emailDialogFor && ( + !open && setEmailDialogFor(null)} + onSaved={(email) => + setUsers((prev) => prev.map((u) => (u.id === emailDialogFor.id ? { ...u, email } : u))) + } + /> + )} + + {passwordDialogFor && ( + !open && setPasswordDialogFor(null)} + /> + )} + + {deleteDialogFor && ( + !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)} + /> + )} + + ); +} diff --git a/components/admin/change-password-dialog.tsx b/components/admin/change-password-dialog.tsx new file mode 100644 index 0000000..2735532 --- /dev/null +++ b/components/admin/change-password-dialog.tsx @@ -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(); + 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 ( + + + + Change password + Set a new password for {userLabel}. + +
+
+ + setPassword(e.target.value)} + /> +
+
+ + setConfirm(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + /> +
+ {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/components/admin/edit-user-email-dialog.tsx b/components/admin/edit-user-email-dialog.tsx new file mode 100644 index 0000000..9f5c247 --- /dev/null +++ b/components/admin/edit-user-email-dialog.tsx @@ -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(); + 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 ( + + + + Edit email + +
+ + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + /> + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/components/admin/user-role-menu.tsx b/components/admin/user-role-menu.tsx new file mode 100644 index 0000000..da25a08 --- /dev/null +++ b/components/admin/user-role-menu.tsx @@ -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 = { + ADMIN: "default", + USER: "secondary", + PENDING: "outline", +}; + +const ROLE_LABEL: Record = { + 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 ( + + + {ROLE_LABEL[current]} + + } + /> + + + {Object.values(Role).map((value) => ( + + {ROLE_LABEL[value]} + + ))} + + + + ); +} diff --git a/components/board/board-context.tsx b/components/board/board-context.tsx index 6676e77..53c66e8 100644 --- a/components/board/board-context.tsx +++ b/components/board/board-context.tsx @@ -13,6 +13,7 @@ import { createGroup, updateGroup as updateGroupAction, deleteGroup as deleteGroupAction, + archiveGroup as archiveGroupAction, reorderGroupsInCategory, moveGroupToCategory, } from "@/lib/actions/groups"; @@ -36,6 +37,7 @@ interface BoardContextValue { data: { title?: string; color?: string } ) => Promise; removeGroup: (groupId: string, categoryId: string) => Promise; + archiveGroup: (groupId: string, categoryId: string) => Promise; reorderGroups: (categoryId: string, orderedIds: string[]) => Promise; moveGroup: ( groupId: string, @@ -110,7 +112,11 @@ export function BoardProvider({ return prev.filter((c) => c.id !== categoryId); }); try { - await deleteCategoryAction(categoryId); + const result = await deleteCategoryAction(categoryId); + if (result?.error) { + setCategories(prevState); + toast.error(result.error); + } } catch { setCategories(prevState); 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[]) => { let prevState: CategoryDTO[] = []; setCategories((prev) => { @@ -339,6 +361,7 @@ export function BoardProvider({ addGroup, editGroup, removeGroup, + archiveGroup, reorderGroups, moveGroup, saveNote, diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index be7e59d..095b748 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useDndContext, useDroppable } from "@dnd-kit/core"; @@ -15,11 +16,14 @@ import { } from "@/components/ui/dropdown-menu"; import { GroupCard } from "@/components/board/group-card"; import { AddGroupPopover } from "@/components/board/add-group-popover"; +import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import { useBoard } from "@/components/board/board-context"; import type { CategoryDTO } from "@/types/board"; export function CategoryLane({ category }: { category: CategoryDTO }) { 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 // 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 // lane) is being dragged removes it from collision detection entirely, // 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"; + // 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 { setNodeRef, @@ -56,59 +67,81 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { const groupIds = category.groups.map((g) => g.id); return ( -
-
- -

{category.name}

- - +
+
+ +

{category.name}

+ + + + + } + /> + + setConfirmOpen(true)} > - - - } - /> - - removeCategory(category.id)} - > - - Delete category - - - + + Delete category + + + +
+ +
+ + {category.groups.map((group) => ( + + ))} + +
+ +
+ +
-
- - {category.groups.map((group) => ( - - ))} - -
- -
- -
-
+ removeCategory(category.id)} + /> + ); } diff --git a/components/board/edit-group-dialog.tsx b/components/board/edit-group-dialog.tsx new file mode 100644 index 0000000..8788efb --- /dev/null +++ b/components/board/edit-group-dialog.tsx @@ -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 ( + + + + Rename group + +
+ + setTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + /> +

+ {title.length}/{TITLE_MAX} +

+
+ + + + +
+
+ ); +} diff --git a/components/board/group-card-overlay.tsx b/components/board/group-card-overlay.tsx index 7735e1e..8f1cd10 100644 --- a/components/board/group-card-overlay.tsx +++ b/components/board/group-card-overlay.tsx @@ -16,14 +16,16 @@ import type { GroupDTO } from "@/types/board"; export function GroupCardOverlay({ group }: { group: GroupDTO }) { const { resolvedTheme } = useTheme(); 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 (

{group.title}

diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 0442236..a78b581 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -1,17 +1,20 @@ "use client"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; 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 { 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { getGroupColor } from "@/lib/colors"; @@ -19,12 +22,16 @@ import { useBoard } from "@/components/board/board-context"; import { NotesDialog } from "@/components/board/notes-dialog"; import { TodoCreatePopover } from "@/components/board/todo-create-popover"; 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"; export function GroupCard({ group }: { group: GroupDTO }) { - const { toggleTodoDone, removeGroup } = useBoard(); + const { toggleTodoDone, removeGroup, archiveGroup } = useBoard(); const { resolvedTheme } = useTheme(); const [editingTodo, setEditingTodo] = useState(null); + const [editOpen, setEditOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const { attributes, @@ -34,54 +41,62 @@ export function GroupCard({ group }: { group: GroupDTO }) { transform, transition, isDragging, + isOver, } = useSortable({ id: group.id, data: { type: "group", categoryId: group.categoryId }, }); - - // dnd-kit's KeyboardSensor only treats Space/Enter as "pick up this card" - // when the key event's target is this specific activator node -- but - // that guard is skipped entirely if activatorNode.current is never set, - // which left it preventDefault()-ing every Space/Enter keydown that - // 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] - ); + // Another card is being dragged and is currently hovering over this one + // -- signal "it'll land near here" with a ring, distinct from this + // card's own accent-colored border. (isDragging guards against this + // card ever ringing itself while it's the one being moved.) + const isDropTarget = isOver && !isDragging; 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 ( <>
-

{group.title}

+
+ +

{group.title}

+
@@ -96,10 +111,12 @@ export function GroupCard({ group }: { group: GroupDTO }) { } /> - removeGroup(group.id, group.categoryId)} - > + setEditOpen(true)}> + + Edit + + + setConfirmOpen(true)}> Delete group @@ -109,33 +126,60 @@ export function GroupCard({ group }: { group: GroupDTO }) {
    - {group.todos.map((todo) => ( -
  • - - toggleTodoDone(todo.id, group.id, group.categoryId, checked === true) - } - className="mt-0.5" - aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`} - /> + {group.todos.map((todo) => { + const todoButton = ( -
  • - ))} + ); + + return ( +
  • + + 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 ? ( + {todoButton} + ) : ( + todoButton + )} +
  • + ); + })}
+ + {canArchive && ( + + )}
{editingTodo && ( @@ -147,6 +191,16 @@ export function GroupCard({ group }: { group: GroupDTO }) { onOpenChange={(open) => !open && setEditingTodo(null)} /> )} + + + + removeGroup(group.id, group.categoryId)} + /> ); } diff --git a/components/board/hand-drawn-check.tsx b/components/board/hand-drawn-check.tsx new file mode 100644 index 0000000..336e0d4 --- /dev/null +++ b/components/board/hand-drawn-check.tsx @@ -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 ( + + ); +} diff --git a/components/board/mouse-follow-tooltip.tsx b/components/board/mouse-follow-tooltip.tsx new file mode 100644 index 0000000..c337702 --- /dev/null +++ b/components/board/mouse-follow-tooltip.tsx @@ -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 ( + setPos(null)} + > + {children} + {pos && + typeof document !== "undefined" && + createPortal( +
+ +
, + document.body + )} +
+ ); +} diff --git a/components/board/notes-dialog.tsx b/components/board/notes-dialog.tsx index 2984406..cd84fb9 100644 --- a/components/board/notes-dialog.tsx +++ b/components/board/notes-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import dynamic from "next/dynamic"; import { useTheme } from "next-themes"; import { NotebookPen, Pencil } from "lucide-react"; @@ -13,17 +12,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets"; import { useBoard } from "@/components/board/board-context"; 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 }) { const { saveNote } = useBoard(); const { resolvedTheme } = useTheme(); diff --git a/components/board/todo-checkbox.tsx b/components/board/todo-checkbox.tsx new file mode 100644 index 0000000..3106cf8 --- /dev/null +++ b/components/board/todo-checkbox.tsx @@ -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 ( + + + {/* 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. */} + + + + ); +} diff --git a/components/board/todo-create-popover.tsx b/components/board/todo-create-popover.tsx index 49291ae..292ad94 100644 --- a/components/board/todo-create-popover.tsx +++ b/components/board/todo-create-popover.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; +import { useTheme } from "next-themes"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { MDEditor } from "@/components/markdown/markdown-widgets"; import { useBoard } from "@/components/board/board-context"; const TITLE_MAX = 20; @@ -20,6 +21,9 @@ export function TodoCreatePopover({ categoryId: string; }) { const { addTodo } = useBoard(); + const { resolvedTheme } = useTheme(); + const colorMode = resolvedTheme === "dark" ? "dark" : "light"; + const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); const [details, setDetails] = useState(""); @@ -53,7 +57,7 @@ export function TodoCreatePopover({ } /> - +
@@ -71,11 +75,11 @@ export function TodoCreatePopover({
-