// ============================================================ // ADMIN — Login + Dashboard (list, search, filter, manage) // ============================================================ function AdminApp({ route, navigate }) { const [session, setSession] = useState(() => api.getSession()); if (!session) return setSession(s)} />; const sub = route.parts[1]; // 'edit' | 'new' | 'categorias' | undefined const id = route.parts[2]; let view = null; if (sub === 'novo') view = navigate('#/admin')} />; else if (sub === 'editar') view = navigate('#/admin')} />; else if (sub === 'categorias') view = ; else view = ; return (
{ api.logout(); setSession(null); navigate('#/admin'); }} />
{view}
); } // ---------- Login (single password gate) ---------- function Login({ onLogin }) { const [pw, setPw] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [show, setShow] = useState(false); const submit = async (e) => { e.preventDefault(); setErr(''); setBusy(true); try { const s = await api.login(pw); onLogin(s); } catch (e) { setErr(e.message); setPw(''); } finally { setBusy(false); } }; return (
Angelo Santos
Painel Editorial

Acesso restrito

Informe a senha para gerenciar as publicações.

{ setPw(e.target.value); setErr(''); }} autoFocus required autoComplete="current-password" placeholder="••••••••" />
{err && (
{err}
)}
Sessão válida por 8 horas neste dispositivo.
← Voltar ao blog
); } // ---------- AdminBar ---------- function AdminBar({ user, active, onLogout }) { return (
Angelo Santos Painel Editorial
olá, {user}
); } // ---------- Dashboard ---------- function Dashboard() { const [articles, setArticles] = useState([]); const [filter, setFilter] = useState('all'); const [q, setQ] = useState(''); const [toast, setToast] = useState(''); const [confirmDel, setConfirmDel] = useState(null); const reload = () => api.listArticles().then(setArticles); useEffect(() => { reload(); }, []); const filtered = useMemo(() => { let a = articles; if (filter !== 'all') a = a.filter(x => x.status === filter); if (q) { const n = q.toLowerCase(); a = a.filter(x => (x.title || '').toLowerCase().includes(n) || (x.subtitle || '').toLowerCase().includes(n)); } return a; }, [articles, filter, q]); const counts = useMemo(() => ({ all: articles.length, published: articles.filter(a => a.status === 'published').length, draft: articles.filter(a => a.status === 'draft').length, scheduled: articles.filter(a => a.status === 'scheduled').length, }), [articles]); const onDelete = async (id) => { await api.deleteArticle(id); setConfirmDel(null); setToast('Artigo excluído.'); reload(); }; const onExport = () => { const data = api.exportData(); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `angelo-santos-blog-${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); setToast('Backup exportado.'); }; const importRef = useRef(null); const onImport = (file) => { if (!file) return; const reader = new FileReader(); reader.onload = async () => { try { await api.importData(reader.result, { merge: true }); setToast('Publicações importadas.'); reload(); } catch (e) { setToast('Arquivo inválido.'); } }; reader.readAsText(file); }; return (

Sala de redação

{counts.all} artigos · {counts.published} publicados · {counts.draft} rascunhos · {counts.scheduled} agendados

{ onImport(e.target.files?.[0]); e.target.value = ''; }} /> Novo artigo
setQ(e.target.value)} />
Capa
Título
Editoria
Status
Data
Ações
{filtered.length === 0 ? (
Nenhum artigo encontrado para os filtros atuais.
) : filtered.map(a => { const cat = categoryById(a.category); return (
{a.cover ? : null}
{a.title} {a.subtitle && {a.subtitle}}
{cat?.name}
{a.status === 'published' ? 'Publicado' : a.status === 'scheduled' ? 'Agendado' : 'Rascunho'}
{a.status === 'scheduled' ? fmtDate(a.scheduledFor) : a.status === 'published' ? fmtDate(a.publishedAt) : `atualizado ${fmtDate(a.updatedAt || a.createdAt)}`}
{a.status === 'published' && ( )}
); })}
{confirmDel && ( setConfirmDel(null)} onConfirm={() => onDelete(confirmDel.id)} /> )} setToast('')} />
); } // ---------- Confirm modal ---------- function ConfirmModal({ title, message, onCancel, onConfirm }) { return (

{title}

{message}

); } // ---------- Categories manager ---------- function CategoriesManager() { const [cats, setCats] = useState([]); const [adding, setAdding] = useState(false); const [draft, setDraft] = useState({ name: '', slug: '', blurb: '' }); const [toast, setToast] = useState(''); useEffect(() => { api.listCategories().then(setCats); }, []); const save = async (next) => { setCats(next); await api.saveCategories(next); setToast('Editorias atualizadas.'); }; const addNew = async () => { if (!draft.name) return; const slug = draft.slug || slugify(draft.name); const next = [...cats, { id: slug, name: draft.name, slug, blurb: draft.blurb || '' }]; await save(next); setAdding(false); setDraft({ name: '', slug: '', blurb: '' }); }; const remove = async (id) => { const next = cats.filter(c => c.id !== id); await save(next); }; const update = async (id, field, value) => { const next = cats.map(c => c.id === id ? { ...c, [field]: value } : c); await save(next); }; return (

Editorias

Organize as seções do blog. Cada artigo pertence a uma editoria.

{!adding && ( )}
{adding && (
setDraft({ ...draft, name: e.target.value })} style={{ width: '100%', padding: 10, border: '1px solid var(--rule)', fontFamily: 'var(--serif)', fontSize: 14 }} />
setDraft({ ...draft, slug: e.target.value })} placeholder={slugify(draft.name)} style={{ width: '100%', padding: 10, border: '1px solid var(--rule)', fontFamily: 'var(--mono)', fontSize: 13 }} />
setDraft({ ...draft, blurb: e.target.value })} style={{ width: '100%', padding: 10, border: '1px solid var(--rule)', fontFamily: 'var(--serif)', fontSize: 14 }} />
)}
Nome
Slug
Descrição
Artigos
Ações
{cats.map(c => ( update(c.id, f, v)} onRemove={() => remove(c.id)} /> ))}
setToast('')} />
); } function CategoryRow({ cat, onUpdate, onRemove }) { const [count, setCount] = useState(0); useEffect(() => { api.listArticles({ category: cat.id }).then(a => setCount(a.length)); }, [cat.id]); return (
e.target.value !== cat.name && onUpdate('name', e.target.value)} style={{ width: '100%', background: 'transparent', border: 0, fontFamily: 'var(--display)', fontSize: 18, padding: 4, outline: 0 }} />
{cat.slug}
e.target.value !== cat.blurb && onUpdate('blurb', e.target.value)} style={{ width: '100%', background: 'transparent', border: 0, fontFamily: 'var(--serif)', fontStyle: 'italic', fontSize: 13, color: 'var(--ink-2)', padding: 4, outline: 0 }} />
{count}
); } Object.assign(window, { AdminApp, Dashboard, CategoriesManager, Login, AdminBar, ConfirmModal });