feat(card): detail page with checklist, comments and history

This commit is contained in:
Dave 2026-09-16 11:04:48 +02:00
parent 0b60c791b5
commit c4a8ad2860
3 changed files with 240 additions and 0 deletions

View file

@ -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 (
<AppShell role={session.role} currentPath={boardHref}>
<TopBar title={detail.project.name} subtitle={detail.client.name}>
{isOwner && (
<CardActions
card={detail.card}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
)}
</TopBar>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="flex items-center gap-3">
<Link
href={boardHref}
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
>
<ArrowLeft size={13} strokeWidth={2} />
Back to board
</Link>
<Pill tone="neutral">{detail.column.name}</Pill>
{!detail.card.accepted &&
detail.card.created_by_role === "client" && (
<Pill tone="brand">Requested</Pill>
)}
</div>
<CardHeader
card={detail.card}
editable={isOwner}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<SubtaskList
cardId={detail.card.id}
subtasks={detail.subtasks}
editable={isOwner}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<CommentThread
cardId={detail.card.id}
comments={detail.comments}
names={detail.names}
role={session.role}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<ActivityTimeline activity={detail.activity} names={detail.names} />
</div>
</div>
</AppShell>
);
}

View file

@ -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 (
<div className="flex items-center gap-2">
{showAccept && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() =>
acceptCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
router.push(`/p/${clientSlug}/${projectSlug}`)
)
)
}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
>
<Check size={14} strokeWidth={2} />
Accept request
</button>
)}
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() =>
archiveCard({ cardId: card.id, clientSlug, projectSlug }).then(() =>
router.push(`/p/${clientSlug}/${projectSlug}`)
)
)
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-raised disabled:opacity-50"
>
<Archive size={14} strokeWidth={2} />
Archive
</button>
</div>
);
}

View file

@ -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 (
<div className="space-y-3">
<h1 className="text-xl font-semibold tracking-tight text-text-primary">
{card.title}
</h1>
{card.description ? (
<p className="whitespace-pre-wrap text-sm text-text-secondary">
{card.description}
</p>
) : (
<p className="text-sm text-text-muted">No description yet.</p>
)}
</div>
);
}
return (
<div className="space-y-3">
<input
value={title}
onChange={(event) => 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"
/>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
onBlur={saveDescription}
disabled={pending}
rows={5}
placeholder="Add a description"
aria-label="Card description"
className="w-full rounded-[var(--radius-tile)] bg-transparent p-2 text-sm text-text-secondary outline-none placeholder:text-text-muted hover:bg-surface focus:bg-surface"
/>
</div>
);
}