mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 12:49:13 -07:00
- Add accent-text and zap-text tokens to all 7 themes for proper contrast on accent/zap-colored buttons (fixes white-on-light-accent in Catppuccin, Nord Frost, Hackerman, Sepia, Gruvbox) - Replace text-white → text-accent-text on all accent buttons (20+ instances) - Replace text-white → text-zap-text on zap buttons - Replace hover:text-white → hover:text-accent-text on follow/action buttons - Replace bg-blue-500 → bg-accent in FountainCard (theme-aware) - Remaining text-white is correct: overlays on bg-black, danger badges
546 lines
21 KiB
TypeScript
546 lines
21 KiB
TypeScript
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  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<HTMLTextAreaElement>(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<string | null>(null);
|
||
const [published, setPublished] = useState(false);
|
||
const [publishedRelays, setPublishedRelays] = useState(0);
|
||
const [lastSaved, setLastSaved] = useState<number | null>(null);
|
||
const [zenMode, setZenMode] = useState(false);
|
||
const [zenHint, setZenHint] = useState(false);
|
||
const zenTextareaRef = useRef<HTMLTextAreaElement>(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<string, string> = {
|
||
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<HTMLTextAreaElement>) => {
|
||
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 = ``;
|
||
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 = ``;
|
||
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 <DraftListView onNewDraft={handleNewDraft} />;
|
||
}
|
||
|
||
// Zen mode — fullscreen distraction-free writing
|
||
if (zenMode) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 bg-bg flex flex-col items-center">
|
||
{/* Exit hint — fades after 2.5s */}
|
||
<div
|
||
className={`absolute top-3 left-1/2 -translate-x-1/2 text-text-dim text-[10px] transition-opacity duration-700 ${
|
||
zenHint ? "opacity-100" : "opacity-0"
|
||
}`}
|
||
>
|
||
Esc or F11 to exit
|
||
</div>
|
||
|
||
<div className="w-full max-w-2xl flex-1 flex flex-col px-6 py-12">
|
||
<input
|
||
value={title}
|
||
onChange={(e) => 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)" }}
|
||
/>
|
||
<textarea
|
||
ref={zenTextareaRef}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyDown={(e) => handleEditorKeyDown(e, zenTextareaRef, content, setContent)}
|
||
placeholder="Write…"
|
||
className="w-full flex-1 bg-transparent text-text text-[17px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none"
|
||
style={{ fontFamily: "var(--font-reading)" }}
|
||
autoFocus
|
||
/>
|
||
<div className="text-text-dim text-[10px] pt-3 text-center">
|
||
{wordCount > 0 ? `${wordCount} words` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
{/* Header */}
|
||
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => setActiveDraft(null)} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||
← drafts
|
||
</button>
|
||
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
|
||
{activeDraft && !published && lastSaved && (
|
||
<span className="text-text-dim text-[10px]">
|
||
· saved {Math.floor((Date.now() - lastSaved) / 1000) < 5 ? "just now" : `${Math.floor((Date.now() - lastSaved) / 1000)}s ago`}
|
||
</span>
|
||
)}
|
||
{published && publishedRelays > 0 && (
|
||
<span className="text-success text-[10px]">· published to {publishedRelays} {publishedRelays === 1 ? "relay" : "relays"}</span>
|
||
)}
|
||
{uploading && (
|
||
<span className="inline-flex items-center gap-1 text-text-dim text-[10px]">
|
||
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
|
||
uploading…
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Write / Preview toggle */}
|
||
<div className="flex border border-border text-[11px]">
|
||
<button
|
||
onClick={() => setMode("write")}
|
||
className={`px-3 py-1 transition-colors ${mode === "write" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
|
||
>
|
||
write
|
||
</button>
|
||
<button
|
||
onClick={() => setMode("preview")}
|
||
className={`px-3 py-1 transition-colors ${mode === "preview" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
|
||
>
|
||
preview
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={toggleZen}
|
||
className="px-3 py-1 text-[11px] border border-border text-text-muted hover:text-text transition-colors"
|
||
title="Focus mode (F11)"
|
||
>
|
||
zen
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setShowMeta((v) => !v)}
|
||
className={`px-3 py-1 text-[11px] border border-border transition-colors ${showMeta ? "text-accent border-accent/40" : "text-text-muted hover:text-text"}`}
|
||
>
|
||
meta
|
||
</button>
|
||
|
||
<button
|
||
onClick={handlePublish}
|
||
disabled={!canPublish || publishing || published}
|
||
className="px-4 py-1 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
{published ? "published ✓" : publishing ? "publishing…" : "publish"}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Meta panel */}
|
||
{showMeta && (
|
||
<div className="border-b border-border px-6 py-3 bg-bg-raised grid grid-cols-2 gap-3 shrink-0">
|
||
<div>
|
||
<label className="text-text-dim text-[10px] block mb-1">Summary</label>
|
||
<textarea
|
||
value={summary}
|
||
onChange={(e) => setSummary(e.target.value)}
|
||
placeholder="A short description…"
|
||
rows={2}
|
||
className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<div>
|
||
<label className="text-text-dim text-[10px] block mb-1">Cover image</label>
|
||
<div className="flex gap-1">
|
||
<input
|
||
value={image}
|
||
onChange={(e) => setImage(e.target.value)}
|
||
placeholder="https://…"
|
||
className="flex-1 bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||
/>
|
||
<button
|
||
onClick={handleCoverImagePick}
|
||
disabled={uploading}
|
||
title="Upload cover image"
|
||
className="px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-text hover:bg-bg-hover transition-colors disabled:opacity-30"
|
||
>
|
||
{uploading ? "…" : "↑"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
|
||
<input
|
||
value={tags}
|
||
onChange={(e) => setTags(e.target.value)}
|
||
placeholder="bitcoin, nostr, essay"
|
||
className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="px-6 py-2 text-danger text-[12px] bg-danger/5 border-b border-border shrink-0">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Editor */}
|
||
<div className="flex-1 overflow-hidden flex flex-col article-editor">
|
||
{/* Title */}
|
||
<div className="px-6 pt-6 pb-2 shrink-0">
|
||
<input
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Title"
|
||
className="w-full bg-transparent text-text text-2xl font-bold placeholder:text-text-dim focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Markdown toolbar */}
|
||
{mode === "write" && (
|
||
<MarkdownToolbar
|
||
textareaRef={textareaRef}
|
||
content={content}
|
||
setContent={setContent}
|
||
setUploading={setUploading}
|
||
setError={setError}
|
||
/>
|
||
)}
|
||
|
||
{/* Inline image previews (write mode only) */}
|
||
{mode === "write" && inlineImages.length > 0 && (
|
||
<div className="flex items-center gap-2 px-6 py-2 border-b border-border bg-bg-raised/50 overflow-x-auto shrink-0">
|
||
<span className="text-text-dim text-[10px] shrink-0">{inlineImages.length} {inlineImages.length === 1 ? "image" : "images"}</span>
|
||
{inlineImages.map((img, i) => (
|
||
<div key={i} className="relative shrink-0 group">
|
||
<img
|
||
src={img.url}
|
||
alt={img.alt}
|
||
className="h-12 w-auto rounded-sm border border-border object-cover"
|
||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Content area */}
|
||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||
{mode === "write" ? (
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
||
onPaste={handleArticlePaste}
|
||
onDrop={async (e) => {
|
||
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
|
||
if (!file) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
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 = ``;
|
||
setContent(content.slice(0, start) + md + content.slice(end));
|
||
}
|
||
} catch (err) {
|
||
setError(`Image upload failed: ${err}`);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}}
|
||
onDragOver={(e) => {
|
||
if (Array.from(e.dataTransfer.types).includes("Files")) e.preventDefault();
|
||
}}
|
||
placeholder="Write your article in Markdown… (paste or drop images)"
|
||
className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono"
|
||
/>
|
||
) : (
|
||
<div
|
||
className="article-preview text-[14px]"
|
||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Draft list view — shown when no active draft is selected */
|
||
function DraftListView({ onNewDraft }: { onNewDraft: () => void }) {
|
||
const { goBack } = useUIStore();
|
||
const { drafts, deleteDraft, setActiveDraft } = useDraftStore();
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||
← back
|
||
</button>
|
||
<h2 className="text-text text-[13px] font-medium">Drafts</h2>
|
||
<span className="text-text-dim text-[11px]">{drafts.length} {drafts.length === 1 ? "draft" : "drafts"}</span>
|
||
</div>
|
||
<button
|
||
onClick={onNewDraft}
|
||
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors"
|
||
>
|
||
new draft
|
||
</button>
|
||
</header>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{drafts.length === 0 && (
|
||
<div className="px-4 py-12 text-center space-y-2">
|
||
<p className="text-text-dim text-[13px]">No drafts yet.</p>
|
||
<p className="text-text-dim text-[11px] opacity-60">
|
||
Click "new draft" to start writing an article.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{drafts.map((draft: ArticleDraft) => {
|
||
const wordCount = draft.content.trim() ? draft.content.trim().split(/\s+/).length : 0;
|
||
const updated = new Date(draft.updatedAt).toLocaleDateString(undefined, {
|
||
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
||
});
|
||
return (
|
||
<div
|
||
key={draft.id}
|
||
className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer flex items-center justify-between"
|
||
onClick={() => setActiveDraft(draft.id)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="text-text text-[13px] font-medium truncate">
|
||
{draft.title || "Untitled"}
|
||
</h3>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className="text-text-dim text-[11px]">{wordCount} words</span>
|
||
<span className="text-text-dim text-[10px]">{updated}</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); deleteDraft(draft.id); }}
|
||
className="text-text-dim hover:text-danger text-[11px] transition-colors px-2"
|
||
title="Delete draft"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|