TitleEditor.tsx

 1import { Pencil } from "lucide-react";
 2import { useState, useRef, useEffect } from "react";
 3
 4import { useBugSetTitleMutation, BugDetailDocument } from "@/__generated__/graphql";
 5import { Button } from "@/components/ui/button";
 6import { Input } from "@/components/ui/input";
 7import { useAuth } from "@/lib/auth";
 8
 9interface TitleEditorProps {
10  bugPrefix: string;
11  title: string;
12  humanId: string;
13  /** Current repo slug, passed as `ref` in refetch query variables. */
14  ref_?: string | null;
15}
16
17// Inline title editor in BugDetailPage. Shows the title as plain text with a
18// pencil icon on hover (auth-gated). Enter saves, Escape cancels.
19export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProps) {
20  const { user } = useAuth();
21  const [editing, setEditing] = useState(false);
22  const [value, setValue] = useState(title);
23  const inputRef = useRef<HTMLInputElement>(null);
24
25  const [setTitle, { loading }] = useBugSetTitleMutation({
26    refetchQueries: [{ query: BugDetailDocument, variables: { ref: ref_, prefix: bugPrefix } }],
27  });
28
29  useEffect(() => {
30    if (editing) inputRef.current?.focus();
31  }, [editing]);
32
33  // Keep local value in sync if title prop changes (e.g. after refetch)
34  useEffect(() => {
35    if (!editing) setValue(title);
36  }, [title, editing]);
37
38  async function handleSave() {
39    const trimmed = value.trim();
40    if (trimmed && trimmed !== title) {
41      await setTitle({ variables: { input: { prefix: bugPrefix, title: trimmed } } });
42    }
43    setEditing(false);
44  }
45
46  function handleKeyDown(e: React.KeyboardEvent) {
47    if (e.key === "Enter") handleSave();
48    if (e.key === "Escape") {
49      setValue(title);
50      setEditing(false);
51    }
52  }
53
54  if (editing) {
55    return (
56      <div className="flex items-start gap-2">
57        <Input
58          ref={inputRef}
59          value={value}
60          onChange={(e) => setValue(e.target.value)}
61          onKeyDown={handleKeyDown}
62          className="text-xl font-semibold"
63          disabled={loading}
64        />
65        <Button size="sm" onClick={handleSave} disabled={loading || !value.trim()}>
66          Save
67        </Button>
68        <Button
69          size="sm"
70          variant="ghost"
71          onClick={() => {
72            setValue(title);
73            setEditing(false);
74          }}
75        >
76          Cancel
77        </Button>
78      </div>
79    );
80  }
81
82  return (
83    <div className="group flex items-start gap-2">
84      <h1 className="flex-1 text-2xl font-semibold leading-tight text-foreground">
85        {title}
86        <span className="ml-2 text-xl font-normal text-muted-foreground">#{humanId}</span>
87      </h1>
88      {user && (
89        <button
90          onClick={() => setEditing(true)}
91          className="mt-1 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
92          title="Edit title"
93        >
94          <Pencil className="size-4" />
95        </button>
96      )}
97    </div>
98  );
99}