// ============================================================
// EDITOR — Rich text editor with preview, cover, tags, schedule
// ============================================================
function Editor({ id, onDone }) {
const [doc, setDoc] = useState(null);
const [categories, setCategories] = useState([]);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState('');
const [previewOn, setPreviewOn] = useState(false);
const bodyRef = useRef(null);
const bodyInitialized = useRef(false);
useEffect(() => {
api.listCategories().then(setCategories);
if (id) {
api.getArticle(id).then(a => setDoc(a || newDoc()));
} else {
setDoc(newDoc());
}
}, [id]);
// Inject html into contenteditable once on load (avoid React reconciling on every keystroke)
useEffect(() => {
if (doc && bodyRef.current && !bodyInitialized.current) {
bodyRef.current.innerHTML = doc.body || '';
bodyInitialized.current = true;
}
}, [doc]);
if (!doc) return
Carregando…
;
const set = (patch) => setDoc(d => ({ ...d, ...patch }));
const onBodyInput = () => {
setDoc(d => ({ ...d, body: bodyRef.current.innerHTML }));
};
const save = async (status) => {
setSaving(true);
const body = bodyRef.current ? bodyRef.current.innerHTML : doc.body;
const payload = {
...doc,
body,
status,
slug: doc.slug || slugify(doc.title) || api.uid(),
author: doc.author || 'Dr. Angelo Santos',
};
if (status === 'published' && !payload.publishedAt) payload.publishedAt = new Date().toISOString();
if (status === 'scheduled' && doc.scheduledFor) payload.publishedAt = doc.scheduledFor;
if (status === 'draft') {} // leave publishedAt as-is
const saved = await api.upsertArticle(payload);
setSaving(false);
setToast(status === 'published' ? 'Artigo publicado.' :
status === 'scheduled' ? 'Artigo agendado.' : 'Rascunho salvo.');
setDoc(saved);
if (!id) setTimeout(() => onDone && onDone(), 600);
};
return (
← Voltar à redação
{id ? 'Editar artigo' : 'Novo artigo'}
{doc.status === 'published' ? 'Publicado' :
doc.status === 'scheduled' ? 'Agendado' : 'Rascunho'}
{readingTime(doc.body)} min de leitura · {(doc.body || '').replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length} palavras
setPreviewOn(true)}>
Pré-visualizar
save('draft')} disabled={saving}>
Salvar rascunho
{doc.status === 'scheduled' || (doc.scheduledFor && doc.status !== 'published') ? (
save('scheduled')} disabled={saving || !doc.scheduledFor}>
Agendar
) : null}
save('published')} disabled={saving || !doc.title}>
{saving ? 'Salvando…' : doc.status === 'published' ? 'Atualizar' : 'Publicar agora'}
{previewOn && (
setPreviewOn(false)} />
)}
setToast('')} />
);
}
function newDoc() {
return {
id: null,
title: '',
subtitle: '',
body: '',
cover: '',
coverCaption: '',
category: 'tribuna',
tags: [],
author: 'Dr. Angelo Santos',
status: 'draft',
publishedAt: null,
scheduledFor: null,
featured: false,
};
}
function toLocalDT(iso) {
// Convert ISO to value for input[type=datetime-local]
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ---------- Rich text toolbar (Medium / Ghost style) ----------
function RichToolbar({ onCommand, onInsertHTML }) {
const [linkOpen, setLinkOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const savedRange = useRef(null);
const imgRef = useRef(null);
const saveSel = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount) savedRange.current = sel.getRangeAt(0).cloneRange();
};
const restoreSel = () => {
if (!savedRange.current) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange.current);
};
const Btn = ({ cmd, val, children, title }) => (
{ e.preventDefault(); onCommand(cmd, val); }}>
{children}
);
const applyLink = () => {
let url = linkUrl.trim();
if (url) {
if (!/^(https?:\/\/|mailto:|#|tel:)/i.test(url)) url = 'https://' + url;
restoreSel();
onCommand('createLink', url);
}
setLinkOpen(false);
setLinkUrl('');
};
const onImg = async (file) => {
if (!file) return;
try {
const url = await api.uploadImage(file);
const html =
` ` +
`Clique para escrever a legenda…
`;
restoreSel();
onInsertHTML(html);
} catch (err) {
alert('Falha ao enviar imagem: ' + err.message);
}
};
return (
);
}
// ---------- Cover uploader ----------
function CoverUploader({ value, onChange }) {
const fileRef = useRef(null);
const [showUrl, setShowUrl] = useState(false);
const [urlVal, setUrlVal] = useState('');
const [uploading, setUploading] = useState(false);
const onFile = async (f) => {
if (!f) return;
setUploading(true);
try {
const url = await api.uploadImage(f);
onChange(url);
} catch (err) {
alert('Falha ao enviar imagem: ' + err.message);
} finally {
setUploading(false);
}
};
return (
!value && fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); onFile(e.dataTransfer.files?.[0]); }}
>
{value ? (
<>
{ e.stopPropagation(); onChange(''); }} title="Remover">×
>
) : (
<>
{uploading ? 'Enviando…' : 'Arraste ou clique para enviar'}
JPG, PNG ou WebP, ratio 16:9 preferido
>
)}
onFile(e.target.files?.[0])} />
{!value && (
{!showUrl ? (
setShowUrl(true)}
style={{ background: 'transparent', border: 0, fontFamily: 'var(--sans)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--gold)', padding: '4px 0', cursor: 'pointer' }}
>
ou usar URL externa
) : (
setUrlVal(e.target.value)}
placeholder="https://…"
style={{ flex: 1, padding: 8, border: '1px solid var(--rule)', fontFamily: 'var(--mono)', fontSize: 12, marginBottom: 0 }}
/>
{ if (urlVal) onChange(urlVal); setShowUrl(false); }}>OK
)}
)}
);
}
// ---------- Tag input ----------
function TagInput({ value, onChange }) {
const [draft, setDraft] = useState('');
const add = () => {
const t = draft.trim().toLowerCase();
if (!t) return;
if (value.includes(t)) { setDraft(''); return; }
onChange([...value, t]);
setDraft('');
};
return (
e.currentTarget.querySelector('input')?.focus()}>
{value.map(t => (
{t}
onChange(value.filter(x => x !== t))}>×
))}
setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(); }
if (e.key === 'Backspace' && !draft && value.length) onChange(value.slice(0, -1));
}}
placeholder={value.length ? '' : 'adicione tags e tecle Enter'}
/>
);
}
// ---------- Preview Modal (renders the article as readers would see it) ----------
function PreviewModal({ doc, onClose }) {
// Simulate published article inside the modal
const cat = categoryById(doc.category);
return (
▸ Pré-visualização · não publicado
{readingTime(doc.body)} min · {(doc.body || '').replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length} palavras
Fechar pré-visualização ×
{doc.cover && (
{doc.coverCaption && {doc.coverCaption} }
)}
Sem conteúdo ainda — comece a escrever.' }} />
);
}
Object.assign(window, { Editor, RichToolbar, CoverUploader, TagInput, PreviewModal });