Vectrum
Sign In
217 lines · 8.6 KB · TypeScript
"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 focus:focus-ring-flush"
            />
          </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>
  );
}