import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { renderMarkdown } from "../../lib/markdown"; import { publishArticle } from "../../lib/nostr"; import { useUIStore } from "../../stores/ui"; import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar"; import { useDraftStore, type ArticleDraft } from "../../stores/drafts"; import { open } from "@tauri-apps/plugin-dialog"; import { readFile } from "@tauri-apps/plugin-fs"; import { uploadBytes, uploadImage } from "../../lib/upload"; import { getCurrentWindow } from "@tauri-apps/api/window"; /** Extract image URLs from markdown ![alt](url) patterns */ function extractImages(md: string): { alt: string; url: string }[] { const re = /!\[([^\]]*)\]\(([^)]+)\)/g; const images: { alt: string; url: string }[] = []; let m; while ((m = re.exec(md))) { images.push({ alt: m[1], url: m[2] }); } return images; } export function ArticleEditor() { const { goBack } = useUIStore(); const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore(); const textareaRef = useRef(null); // If no active draft, show draft list const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null; const [title, setTitle] = useState(activeDraft?.title || ""); const [content, setContent] = useState(activeDraft?.content || ""); const [summary, setSummary] = useState(activeDraft?.summary || ""); const [image, setImage] = useState(activeDraft?.image || ""); const [tags, setTags] = useState(activeDraft?.tags || ""); const [mode, setMode] = useState<"write" | "preview">("write"); const [showMeta, setShowMeta] = useState(false); const [publishing, setPublishing] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [published, setPublished] = useState(false); const [publishedRelays, setPublishedRelays] = useState(0); const [lastSaved, setLastSaved] = useState(null); const [zenMode, setZenMode] = useState(false); const [zenHint, setZenHint] = useState(false); const zenTextareaRef = useRef(null); const toggleZen = useCallback(async () => { const win = getCurrentWindow(); if (zenMode) { await win.setFullscreen(false); setZenMode(false); } else { await win.setFullscreen(true); setZenMode(true); setZenHint(true); setTimeout(() => setZenHint(false), 2500); } }, [zenMode]); // F11 to toggle zen mode, Esc to exit useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "F11") { e.preventDefault(); toggleZen(); } if (e.key === "Escape" && zenMode) { toggleZen(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [toggleZen, zenMode]); // Exit fullscreen on unmount useEffect(() => { return () => { getCurrentWindow().setFullscreen(false).catch(() => {}); }; }, []); // Sync state when active draft changes useEffect(() => { if (activeDraft) { setTitle(activeDraft.title); setContent(activeDraft.content); setSummary(activeDraft.summary); setImage(activeDraft.image); setTags(activeDraft.tags); setPublished(false); setError(null); } }, [activeDraftId]); // Auto-save to draft store useEffect(() => { if (!activeDraftId) return; const t = setTimeout(() => { updateDraft(activeDraftId, { title, content, summary, image, tags }); setLastSaved(Date.now()); }, 1000); return () => clearTimeout(t); }, [title, content, summary, image, tags, activeDraftId]); // Update "saved Xs ago" display every 10s const [, setTick] = useState(0); useEffect(() => { if (!lastSaved) return; const iv = setInterval(() => setTick((t) => t + 1), 10000); return () => clearInterval(iv); }, [lastSaved]); const renderedHtml = renderMarkdown(content || "*Nothing to preview yet.*"); const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const canPublish = title.trim().length > 0 && content.trim().length > 0; const inlineImages = useMemo(() => extractImages(content), [content]); const handlePublish = async () => { if (!canPublish || publishing) return; setPublishing(true); setError(null); try { const result = await publishArticle({ title: title.trim(), content: content.trim(), summary: summary.trim() || undefined, image: image.trim() || undefined, tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean), }); if (activeDraftId) deleteDraft(activeDraftId); setPublished(true); setPublishedRelays(result.relayCount); if (result.relayCount === 0) { setError("Warning: no relays confirmed — your article may not have been published."); } setTimeout(goBack, 2000); } catch (err) { setError(`Failed to publish: ${err}`); } finally { setPublishing(false); } }; const handleNewDraft = () => { const id = createDraft(); setActiveDraft(id); }; const handleCoverImagePick = async () => { try { const selected = await open({ multiple: false, filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp"] }], }); if (!selected) return; setUploading(true); setError(null); try { const filePath = typeof selected === "string" ? selected : selected; const bytes = await readFile(filePath); const fileName = filePath.split(/[\\/]/).pop() || "cover.jpg"; const ext = fileName.split(".").pop()?.toLowerCase() || "jpg"; const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", }; const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/jpeg"); setImage(url); } finally { setUploading(false); } } catch (err) { setError(`Cover upload failed: ${err}`); } }; const handleArticlePaste = async (e: React.ClipboardEvent) => { const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); if (fileFromFiles) { e.preventDefault(); setUploading(true); setError(null); try { const url = await uploadImage(fileFromFiles); const ta = textareaRef.current; if (ta) { const start = ta.selectionStart ?? content.length; const end = ta.selectionEnd ?? content.length; const md = `![image](${url})`; setContent(content.slice(0, start) + md + content.slice(end)); setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + md.length; ta.focus(); }, 0); } } catch (err) { setError(`Image upload failed: ${err}`); } finally { setUploading(false); } return; } const items = Array.from(e.clipboardData.items ?? []); const imageItem = items.find((item) => item.type.startsWith("image/")); if (imageItem) { const file = imageItem.getAsFile(); if (file) { e.preventDefault(); setUploading(true); setError(null); try { const url = await uploadImage(file); const ta = textareaRef.current; if (ta) { const start = ta.selectionStart ?? content.length; const end = ta.selectionEnd ?? content.length; const md = `![image](${url})`; setContent(content.slice(0, start) + md + content.slice(end)); setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + md.length; ta.focus(); }, 0); } } catch (err) { setError(`Image upload failed: ${err}`); } finally { setUploading(false); } } } }; // If no active draft, show the draft list if (!activeDraftId) { return ; } // Zen mode — fullscreen distraction-free writing if (zenMode) { return (
{/* Exit hint — fades after 2.5s */}
Esc or F11 to exit
setTitle(e.target.value)} placeholder="Title" className="w-full bg-transparent text-text text-3xl font-bold placeholder:text-text-dim focus:outline-none mb-6" style={{ fontFamily: "var(--font-reading)" }} />