TitleEditor.tsx

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