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