From c4a8ad2860200f4166993eb7ca697e66971df8d1 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:04:48 +0200 Subject: [PATCH] feat(card): detail page with checklist, comments and history --- .../[projectSlug]/c/[cardId]/page.tsx | 89 ++++++++++++++++++ src/components/card/card-actions.tsx | 61 +++++++++++++ src/components/card/card-header.tsx | 90 +++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx create mode 100644 src/components/card/card-actions.tsx create mode 100644 src/components/card/card-header.tsx diff --git a/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx new file mode 100644 index 0000000..cef8128 --- /dev/null +++ b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx @@ -0,0 +1,89 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { CardHeader } from "@/components/card/card-header"; +import { CardActions } from "@/components/card/card-actions"; +import { SubtaskList } from "@/components/card/subtask-list"; +import { CommentThread } from "@/components/card/comment-thread"; +import { ActivityTimeline } from "@/components/card/activity-timeline"; +import { Pill } from "@/components/ui/pill"; +import { getSession } from "@/lib/auth/session"; +import { loadCardDetail } from "@/lib/data/card"; + +export default async function CardPage({ + params, +}: { + params: Promise<{ clientSlug: string; projectSlug: string; cardId: string }>; +}) { + const session = await getSession(); + if (!session) redirect("/login"); + + const { clientSlug, projectSlug, cardId } = await params; + const detail = await loadCardDetail(clientSlug, projectSlug, cardId); + if (!detail) notFound(); + + const isOwner = session.role === "owner"; + const boardHref = `/p/${clientSlug}/${projectSlug}`; + + return ( + + + {isOwner && ( + + )} + + +
+
+
+ + + Back to board + + {detail.column.name} + {!detail.card.accepted && + detail.card.created_by_role === "client" && ( + Requested + )} +
+ + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/card/card-actions.tsx b/src/components/card/card-actions.tsx new file mode 100644 index 0000000..43645f8 --- /dev/null +++ b/src/components/card/card-actions.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Archive } from "lucide-react"; + +import { acceptCard, archiveCard } from "@/app/actions/cards"; +import type { CardRecord } from "@/lib/pb/types"; + +export function CardActions({ + card, + clientSlug, + projectSlug, +}: { + card: CardRecord; + clientSlug: string; + projectSlug: string; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + const showAccept = !card.accepted && card.created_by_role === "client"; + + return ( +
+ {showAccept && ( + + )} + + +
+ ); +} \ No newline at end of file diff --git a/src/components/card/card-header.tsx b/src/components/card/card-header.tsx new file mode 100644 index 0000000..146407a --- /dev/null +++ b/src/components/card/card-header.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import { updateCard } from "@/app/actions/cards"; +import type { CardRecord } from "@/lib/pb/types"; + +/** + * Title and description. + * + * Editable in place for the owner, read-only for a client manager. The client + * may not change these — their two editable fields are priority and target + * date, which live on the board tile and go through /api/client/*. + */ +export function CardHeader({ + card, + editable, + clientSlug, + projectSlug, +}: { + card: CardRecord; + editable: boolean; + clientSlug: string; + projectSlug: string; +}) { + const [pending, startTransition] = useTransition(); + const [title, setTitle] = useState(card.title); + const [description, setDescription] = useState(card.description); + + function saveTitle() { + if (title.trim() === card.title || !title.trim()) { + setTitle(card.title); + return; + } + startTransition(() => + updateCard({ cardId: card.id, title, clientSlug, projectSlug }).then( + () => undefined + ) + ); + } + + function saveDescription() { + if (description === card.description) return; + startTransition(() => + updateCard({ cardId: card.id, description, clientSlug, projectSlug }).then( + () => undefined + ) + ); + } + + if (!editable) { + return ( +
+

+ {card.title} +

+ {card.description ? ( +

+ {card.description} +

+ ) : ( +

No description yet.

+ )} +
+ ); + } + + return ( +
+ setTitle(event.target.value)} + onBlur={saveTitle} + disabled={pending} + aria-label="Card title" + className="w-full rounded-lg bg-transparent text-xl font-semibold tracking-tight text-text-primary outline-none hover:bg-surface focus:bg-surface" + /> +