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