Spaces:
Paused
Paused
| 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' 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…' 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> | |
| ) | |
| } | |