"use client";
import Link from "next/link";
import { useEffect, useOptimistic, useState, useTransition, type ReactNode } from "react";
import { updateIssueAction } from "@/app/actions/issues";
import { StatusBadge, Textarea } from "@unlocalhosted/metalui";
import { CheckIcon, ChevronRightIcon, PencilIcon } from "@/components/icons";
import { Box, Button } from "@/components/ui";
import { applyPatch, statusLabel, type Issue, type IssuePatch } from "@/lib/issue-meta";
import { STATUS_LED } from "./issue-icons";
import { AssigneePicker, DueDateField, EstimatePicker, LabelsPicker, PriorityPicker, StatusPicker } from "./properties";
import { useTracker } from "./tracker-context";
type PickerKind = "status" | "priority" | "assignee" | "labels";
const SHORTCUT_PICKERS: Record<string, PickerKind> = { s: "status", p: "priority", a: "assignee", l: "labels" };
/**
* An issue's title, description and properties, all editable in place. The
* rendered description, the activity feed and the sidebar footer are server-rendered slots.
*/
export function IssueDetail({
issue: serverIssue,
description,
sidebarFooter,
children,
}: {
issue: Issue;
description: ReactNode;
sidebarFooter: ReactNode;
children: ReactNode;
}) {
// Only collaborators (canTriage) change issues; everyone else can just comment.
const { owner, name, base, members, labels, canTriage, notify } = useTracker();
const [issue, applyOptimistic] = useOptimistic(serverIssue, (i, patch: IssuePatch) => applyPatch(i, patch, { members, labels }));
const [pending, startTransition] = useTransition();
const [editingTitle, setEditingTitle] = useState(false);
const [editingBody, setEditingBody] = useState(false);
const [openPicker, setOpenPicker] = useState<PickerKind | null>(null);
const update = (patch: IssuePatch, then?: () => void) =>
startTransition(async () => {
applyOptimistic(patch);
const result = await updateIssueAction(owner, name, issue.number, patch);
if (result.error) notify(result.error, { tone: "error" });
else then?.();
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (e.metaKey || e.ctrlKey || e.altKey || target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const kind = SHORTCUT_PICKERS[e.key.toLowerCase()];
if (!kind || !canTriage) return;
e.preventDefault();
setOpenPicker(kind);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [canTriage]);
const picker = (kind: PickerKind) => ({
open: openPicker === kind ? true : undefined,
onOpenChange: (open: boolean) => !open && setOpenPicker(null),
});
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="min-w-0">
<div className="mb-3 flex flex-wrap items-center gap-3">
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 type-ui text-ink2">
<Link href={`${base}/issues`} className="rounded-sm transition-colors hover:text-ink focus-visible:focus-ring">
Issues
</Link>
<ChevronRightIcon width={12} height={12} className="text-ink3" />
<span aria-current="page" className="font-mono text-ink">
#{issue.number}
</span>
</nav>
<StatusBadge led={STATUS_LED[issue.status]}>{statusLabel(issue.status)}</StatusBadge>
</div>
{editingTitle ? (
<form
onSubmit={(e) => {
e.preventDefault();
const title = String(new FormData(e.currentTarget).get("title") ?? "").trim();
if (title && title !== issue.title) update({ title });
setEditingTitle(false);
}}
>
<input
name="title"
autoFocus
defaultValue={issue.title}
maxLength={256}
aria-label="Title"
onBlur={(e) => e.currentTarget.form?.requestSubmit()}
onKeyDown={(e) => e.key === "Escape" && setEditingTitle(false)}
className="-mx-2 w-[calc(100%+1rem)] rounded-row px-2 py-0.5 text-[26px] leading-8 font-semibold tracking-[-0.025em] text-ink caret-green-deep recipe-well-field outline-none"
/>
</form>
) : (
<h1
onClick={() => canTriage && setEditingTitle(true)}
className={`text-[26px] leading-8 font-semibold tracking-[-0.025em] text-ink ${canTriage ? "-mx-2 cursor-text rounded-row px-2 py-0.5 transition-[background,box-shadow] hover:recipe-row-list-hover" : ""}`}
title={canTriage ? "Click to edit" : undefined}
>
{issue.title}
</h1>
)}
<div className="mt-5 mb-8">
{editingBody ? (
<form
onSubmit={(e) => {
e.preventDefault();
const body = String(new FormData(e.currentTarget).get("body") ?? "");
update({ body }, () => setEditingBody(false));
}}
className="space-y-2"
>
<Textarea
name="body"
autoFocus
minRows={8}
maxRows={24}
defaultValue={serverIssue.body}
placeholder="Add a description… Markdown is supported."
aria-label="Description"
onKeyDown={(e) => {
if (e.key === "Escape") setEditingBody(false);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) e.currentTarget.form?.requestSubmit();
}}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="invisible" onClick={() => setEditingBody(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" icon={<CheckIcon />} disabled={pending}>
{pending ? "Saving…" : "Save"}
</Button>
</div>
</form>
) : (
<div className="group relative">
{serverIssue.body ? description : <p className="type-body text-ink3 italic">No description provided.</p>}
{canTriage && (
<Button type="button" variant="invisible" icon={<PencilIcon />} onClick={() => setEditingBody(true)} className="mt-2 -ml-3">
{serverIssue.body ? "Edit description" : "Add description"}
</Button>
)}
</div>
)}
</div>
{children}
</div>
<aside className="self-start lg:sticky lg:top-4">
<Box className="p-2">
<Property label="Status">
<StatusPicker
variant="row"
value={issue.status}
onChange={(status) => update({ status })}
disabled={!canTriage}
{...picker("status")}
/>
</Property>
<Property label="Priority">
<PriorityPicker
variant="row"
value={issue.priority}
onChange={(priority) => update({ priority })}
disabled={!canTriage}
{...picker("priority")}
/>
</Property>
<Property label="Assignee">
<AssigneePicker
variant="row"
value={issue.assigneeId}
onChange={(assigneeId) => update({ assigneeId })}
disabled={!canTriage}
{...picker("assignee")}
/>
</Property>
<Property label="Labels">
<LabelsPicker
variant="row"
value={issue.labels.map((l) => l.id)}
onChange={(labelIds) => update({ labelIds })}
disabled={!canTriage}
{...picker("labels")}
/>
</Property>
<Property label="Estimate">
<EstimatePicker variant="row" value={issue.estimate} onChange={(estimate) => update({ estimate })} disabled={!canTriage} />
</Property>
<Property label="Due date">
<DueDateField variant="row" value={issue.dueDate} onChange={(dueDate) => update({ dueDate })} disabled={!canTriage} />
</Property>
<div className="rule-t mt-2 px-2 pt-3 pb-1 type-meta text-ink2">{sidebarFooter}</div>
</Box>
</aside>
</div>
);
}
function Property({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[84px_minmax(0,1fr)] items-center gap-1">
<span className="px-2 type-label engraved">{label}</span>
{children}
</div>
);
}