File size: 8,033 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
import { useState } from 'react'
import { api, runJob } from '../api'
import { useJob, ProgressBox, ErrorBox } from '../hooks'
import PromptPanel from '../components/PromptPanel'

export default function TopicsStep({ data, refreshProject, projectId, selectReel }) {
  const [count, setCount] = useState(8)
  const [picked, setPicked] = useState(null)
  const [editing, setEditing] = useState(null)
  const [editTopic, setEditTopic] = useState(null) // {title, reason, popularity_signal}
  const [prompt, setPrompt] = useState(null)
  const [creating, setCreating] = useState(false)
  const job = useJob()

  const topics = data.topics?.topics || []

  const generate = async () => {
    try {
      await job.start(`/api/projects/${projectId}/topics/generate`, { n: Number(count), prompt })
      await refreshProject()
      setPicked(null)
    } catch { /* shown */ }
  }

  // A legacy single-string signal looks like:
  //   "Source ('quote'), Other ('q1', 'q2'), r/sub ('quote')"
  // Split only on ")," boundaries between sources, so commas *inside* the
  // parenthetical quotes don't split. Falls back to the whole string.
  const splitLegacy = (str) => {
    const parts = str.split(/\)\s*,\s*/)
    return parts
      .map((p, i) => (i < parts.length - 1 ? p + ')' : p).trim())
      .filter(Boolean)
  }

  // Signals may arrive as the new list, a one-item list still holding a
  // combined string, or the legacy single string. Split EVERY entry on the
  // "), " source boundaries (idempotent: already-clean items pass through).
  const signalsOf = (t) => {
    const raw = (t.signals && t.signals.length) ? t.signals
      : (t.popularity_signal ? [t.popularity_signal] : [])
    return raw.flatMap((s) => splitLegacy(String(s)))
  }

  const startEdit = (i) => { setEditing(i); setEditTopic({ ...topics[i], signals: signalsOf(topics[i]) }) }

  const saveEdit = async (i) => {
    // Clean up signal lines (drop blanks/whitespace) only at save time.
    const clean = { ...editTopic, signals: (editTopic.signals || []).map((s) => s.trim()).filter(Boolean) }
    const updated = topics.map((t, j) => (j === i ? { ...t, ...clean } : t))
    await api.put(`/api/projects/${projectId}/topics`, { topics: updated })
    await refreshProject()
    setEditing(null)
  }

  const addCustom = async () => {
    const fresh = { title: "New topic", reason: "added manually", signals: [] }
    await api.put(`/api/projects/${projectId}/topics`, { topics: [fresh, ...topics] })
    await refreshProject()
    setEditing(0)
    setEditTopic({ ...fresh })
  }

  const removeTopic = async (i) => {
    const updated = topics.filter((_, j) => j !== i)
    await api.put(`/api/projects/${projectId}/topics`, { topics: updated })
    await refreshProject()
    setPicked(null)
  }

  // Create a reel for the chosen topic and open it on the Script step,
  // where the user can QC/edit the script prompt before generating.
  const useTopic = async () => {
    setCreating(true)
    try {
      const reel = await api.post(`/api/projects/${projectId}/reels`, { topic: topics[picked].title })
      await refreshProject()
      selectReel(reel.id)
    } catch (e) {
      job.setError(e.message)
    } finally {
      setCreating(false)
    }
  }

  return (
    <div>
      <h2>Topics</h2>
      <p className="subtitle">
        Ranked from your reference channels&apos; top Shorts plus news and Reddit signals. Edit any topic inline, then turn one into a reel.
      </p>

      <div className="card">
        <div className="row">
          <label className="row" style={{ gap: 8 }}>
            <span className="muted">How many:</span>
            <input type="number" min="1" max="20" style={{ width: 70 }} value={count}
              onChange={(e) => setCount(e.target.value)} />
          </label>
          <button className="btn" onClick={generate} disabled={job.running}>
            {job.running ? 'Generating…' : topics.length ? 'Regenerate topics' : 'Generate topics'}
          </button>
        </div>
        <div style={{ marginTop: 10 }}>
          <PromptPanel
            label="topic-ranking"
            value={prompt}
            setValue={setPrompt}
            disabled={job.running}
            fetchPrompt={async () => (await runJob(`/api/projects/${projectId}/topics/prompt`, { n: Number(count) })).prompt}
          />
        </div>
      </div>

      <ErrorBox error={job.error} onRetry={generate} />
      {job.running && <ProgressBox progress={job.progress.length ? job.progress : ['Collecting signals…']} />}

      {(topics.length > 0 || data.topics) && (
        <div className="card">
          <div className="row" style={{ marginBottom: topics.length ? 8 : 0 }}>
            <strong className="grow">Topic pool ({topics.length})</strong>
            <button className="btn small secondary" onClick={addCustom}>+ Add your own topic</button>
          </div>
          {topics.map((t, i) => (
            <div key={i} className={`topic-row ${picked === i ? 'selected' : ''}`}>
              <input type="radio" name="topic" checked={picked === i} onChange={() => setPicked(i)}
                style={{ marginTop: 5 }} />
              <div className="grow">
                {editing === i ? (
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                    <label className="field" style={{ margin: 0 }}>
                      <span className="lbl">Title</span>
                      <input type="text" value={editTopic.title} autoFocus
                        onChange={(e) => setEditTopic({ ...editTopic, title: e.target.value })}
                        onKeyDown={(e) => e.key === 'Enter' && saveEdit(i)} />
                    </label>
                    <label className="field" style={{ margin: 0 }}>
                      <span className="lbl">Description (why this topic)</span>
                      <textarea value={editTopic.reason} style={{ minHeight: 56 }}
                        onChange={(e) => setEditTopic({ ...editTopic, reason: e.target.value })} />
                    </label>
                    <label className="field" style={{ margin: 0 }}>
                      <span className="lbl">Supporting signals (one per line)</span>
                      <textarea style={{ minHeight: 70 }}
                        value={(editTopic.signals || []).join('\n')}
                        placeholder="FutbolEssence — 'Arsenal Reaction…'&#10;r/soccer — 'High schoolers react…'"
                        onChange={(e) => setEditTopic({ ...editTopic, signals: e.target.value.split('\n') })} />
                    </label>
                    <div className="row">
                      <button className="btn small" onClick={() => saveEdit(i)}>Save</button>
                      <button className="btn small secondary" onClick={() => setEditing(null)}>Cancel</button>
                    </div>
                  </div>
                ) : (
                  <>
                    <strong style={{ cursor: 'pointer' }} onClick={() => setPicked(i)}>{t.title}</strong>
                    {t.reason && <div className="topic-meta">{t.reason}</div>}
                    {signalsOf(t).length > 0 && (
                      <ul className="signal-list">
                        {signalsOf(t).map((s, k) => <li key={k}>{s}</li>)}
                      </ul>
                    )}
                  </>
                )}
              </div>
              {editing !== i && (
                <>
                  <button className="btn small secondary" onClick={() => startEdit(i)}>Edit</button>
                  <button className="btn small danger" onClick={() => removeTopic(i)}>Remove</button>
                </>
              )}
            </div>
          ))}
        </div>
      )}

      {topics.length > 0 && (
        <button className="btn" disabled={picked === null || creating} onClick={useTopic}>
          {creating ? 'Creating reel…' : 'Create a reel for this topic →'}
        </button>
      )}
    </div>
  )
}