File size: 9,701 Bytes
3d2098f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import { useEffect, useState } from 'react'
import { api, runJob } from '../api'
import { useJob, ProgressBox, ErrorBox } from '../hooks'
import PromptPanel from '../components/PromptPanel'

const IG_TOOLTIP =
  'Instagram pages are style-only: we never fetch or scrape them (against ' +
  "Instagram's terms, and there is no public API). Your notes feed the " +
  'style prompt as text instead.'

export default function ReferencesStep({ data, refreshProject, setStep, projectId }) {
  const project = data.project
  const [draft, setDraft] = useState({ kind: 'youtube', name: '', url: '', notes: '' })
  const [busy, setBusy] = useState(false)
  const [suggestPrompt, setSuggestPrompt] = useState(null)
  const [stylePrompt, setStylePrompt] = useState(null)
  const suggestJob = useJob()
  const styleJob = useJob()

  // Editable style-card output
  const [card, setCard] = useState(data.style?.style_card || '')
  const [cardDirty, setCardDirty] = useState(false)
  const [cardSaving, setCardSaving] = useState(false)
  useEffect(() => { setCard(data.style?.style_card || ''); setCardDirty(false) }, [data.style])

  const saveRefs = async (references) => {
    setBusy(true)
    try {
      await api.patch(`/api/projects/${projectId}`, {
        name: project.name, niche: project.niche, format: project.format, references,
      })
      await refreshProject()
    } finally {
      setBusy(false)
    }
  }

  const addDraft = async () => {
    if (!draft.name.trim() && !draft.url.trim()) return
    await saveRefs([...project.references, { ...draft, channel_id: '' }])
    setDraft({ kind: 'youtube', name: '', url: '', notes: '' })
  }

  const remove = (i) => saveRefs(project.references.filter((_, j) => j !== i))

  // Inline editing of an existing reference entry.
  const [editIdx, setEditIdx] = useState(null)
  const [editRef, setEditRef] = useState(null)
  const startEdit = (i) => { setEditIdx(i); setEditRef({ ...project.references[i] }) }
  const saveEdit = async () => {
    // Editing the name/url means the channel must be re-resolved, so clear it.
    const next = project.references.map((r, j) => (j === editIdx ? { ...editRef, channel_id: "" } : r))
    setEditIdx(null)
    await saveRefs(next)
  }

  const suggest = async () => {
    try {
      const suggested = await suggestJob.start(`/api/projects/${projectId}/references/suggest`,
        { n: 5, prompt: suggestPrompt })
      const existing = new Set(project.references.map((r) => r.name.toLowerCase()))
      const fresh = suggested.filter((r) => !existing.has(r.name.toLowerCase()))
      if (fresh.length) await saveRefs([...project.references, ...fresh])
    } catch { /* shown */ }
  }

  const buildStyle = async () => {
    try {
      await styleJob.start(`/api/projects/${projectId}/style/refresh`, { prompt: stylePrompt })
      await refreshProject()
    } catch { /* shown */ }
  }

  const saveCard = async () => {
    setCardSaving(true)
    try {
      await api.put(`/api/projects/${projectId}/style`, { ...data.style, style_card: card })
      await refreshProject()
      setCardDirty(false)
    } catch (e) {
      styleJob.setError(e.message)
    } finally {
      setCardSaving(false)
    }
  }

  return (
    <div>
      <h2>Reference channels</h2>
      <p className="subtitle">
        YouTube references are data sources (top Shorts + transcripts drive topics and style).
        Instagram references are style-only inspiration.
      </p>

      <div className="card">
        {project.references.length === 0 && <p className="muted">No references yet — add some or use Suggest.</p>}
        {project.references.map((r, i) => (
          editIdx === i ? (
            <div key={i} className="topic-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
              <div className="row">
                <input type="text" className="grow" placeholder="name / @handle"
                  value={editRef.name} onChange={(e) => setEditRef({ ...editRef, name: e.target.value })} />
                <input type="text" className="grow" placeholder="URL (optional)"
                  value={editRef.url} onChange={(e) => setEditRef({ ...editRef, url: e.target.value })} />
              </div>
              {editRef.kind === 'instagram_style' && (
                <textarea placeholder="style notes" value={editRef.notes}
                  onChange={(e) => setEditRef({ ...editRef, notes: e.target.value })} />
              )}
              <div className="row">
                <button className="btn small" onClick={saveEdit} disabled={busy}>Save</button>
                <button className="btn small secondary" onClick={() => setEditIdx(null)}>Cancel</button>
              </div>
            </div>
          ) : (
            <div key={i} className="topic-row">
              <div className="grow">
                <strong>{r.name || r.url}</strong>{' '}
                {r.kind === 'instagram_style'
                  ? <span className="tag ig" title={IG_TOOLTIP}>style only ⓘ</span>
                  : <span className="tag">youtube</span>}
                {r.url && <div className="topic-meta">{r.url}</div>}
                {r.notes && <div className="topic-meta">{r.notes}</div>}
              </div>
              <button className="btn small secondary" disabled={busy} onClick={() => startEdit(i)}>Edit</button>
              <button className="btn small danger" disabled={busy} onClick={() => remove(i)}>Remove</button>
            </div>
          )
        ))}
      </div>

      <div className="card">
        <div className="row">
          <select
            style={{ width: 170 }}
            value={draft.kind}
            onChange={(e) => setDraft({ ...draft, kind: e.target.value })}
          >
            <option value="youtube">YouTube channel</option>
            <option value="instagram_style">Instagram (style only)</option>
          </select>
          <input
            type="text" className="grow" placeholder="@handle, URL, or channel name"
            value={draft.kind === 'youtube' ? draft.name : draft.url}
            onChange={(e) => setDraft(draft.kind === 'youtube'
              ? { ...draft, name: e.target.value }
              : { ...draft, url: e.target.value, name: e.target.value.split('/').filter(Boolean).pop() || '' })}
          />
          <button className="btn secondary" onClick={addDraft} disabled={busy}>Add</button>
        </div>
        {draft.kind === 'instagram_style' && (
          <label className="field" style={{ marginTop: 12 }}>
            <span className="lbl">Style notes (what to imitate — pacing, captions, color, vibe)</span>
            <textarea value={draft.notes} onChange={(e) => setDraft({ ...draft, notes: e.target.value })} />
          </label>
        )}
      </div>

      {/* Suggest channels — with editable prompt */}
      <div className="card">
        <strong>Suggest channels</strong>
        <p className="muted" style={{ margin: '4px 0 8px' }}>AI proposes channels for your niche and verifies each on YouTube.</p>
        <PromptPanel
          label="channel-suggestion"
          value={suggestPrompt}
          setValue={setSuggestPrompt}
          disabled={suggestJob.running}
          fetchPrompt={async () => (await api.post(`/api/projects/${projectId}/references/suggest/prompt`, { n: 5 })).prompt}
        />
        <button className="btn secondary" onClick={suggest} disabled={suggestJob.running}>
          {suggestJob.running ? 'Suggesting…' : '✨ Suggest channels'}
        </button>
        {suggestJob.running && <ProgressBox progress={['Asking the AI for channels and verifying them on YouTube…']} />}
        <ErrorBox error={suggestJob.error} />
      </div>

      {/* Style profile — editable prompt + editable output card */}
      <div className="card">
        <strong>Style profile</strong>
        <p className="muted" style={{ margin: '4px 0 8px' }}>
          Distilled from your references&apos; top Shorts. Edit the prompt before building, and edit the resulting card.
        </p>
        <PromptPanel
          label="style"
          value={stylePrompt}
          setValue={setStylePrompt}
          disabled={styleJob.running}
          fetchPrompt={async () => (await runJob(`/api/projects/${projectId}/style/prompt`, {})).prompt}
        />
        <button className="btn secondary" onClick={buildStyle} disabled={styleJob.running}>
          {styleJob.running ? 'Building…' : data.style ? 'Rebuild style profile' : 'Build style profile'}
        </button>
        {styleJob.running && <ProgressBox progress={styleJob.progress.length ? styleJob.progress : ['Building…']} />}
        <ErrorBox error={styleJob.error} />

        {data.style && (
          <div style={{ marginTop: 14 }}>
            <span className="lbl" style={{ color: 'var(--muted)', fontSize: 13 }}>
              Style card (editable) {data.style.built_at && <span className="muted">· built {data.style.built_at}</span>}
            </span>
            <textarea
              style={{ minHeight: 160, marginTop: 6 }}
              value={card}
              onChange={(e) => { setCard(e.target.value); setCardDirty(true) }}
            />
            <div className="row">
              {cardDirty && <button className="btn small" onClick={saveCard} disabled={cardSaving}>
                {cardSaving ? 'Saving…' : 'Save edits'}
              </button>}
              <span className="muted">{data.style.exemplars.length} exemplar transcript(s) kept for few-shot.</span>
            </div>
          </div>
        )}
      </div>

      <button className="btn" onClick={() => setStep(2)}>Continue to Topics →</button>
    </div>
  )
}