V1 Changes
This commit is contained in:
parent
0d7336858c
commit
8639f9986c
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
12
auth.ts
|
|
@ -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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Group" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue