reel-studio-2 / web /src /steps /TopicsStep.jsx
xvepkj's picture
initial commit
3d2098f
Raw
History Blame
8.03 kB
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>
)
}