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