Add zen writing mode and auto-save indicator with relay confirmation

Zen mode: fullscreen distraction-free writing via F11 or "zen" button;
hides all chrome, centers content in serif font at 17px, shows only
title + content + word count. Esc/F11 to exit, exits on unmount.

Auto-save indicator: shows "saved Xs ago" in editor header, updates
every 10s. After publishing, shows "published to N relays" confirmation
in green, or warning if zero relays confirmed.
This commit is contained in:
Jure
2026-03-20 11:05:05 +01:00
parent afb8fed97b
commit 93ca13cc51
2 changed files with 114 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { marked } from "marked"; import { marked } from "marked";
import { publishArticle } from "../../lib/nostr"; import { publishArticle } from "../../lib/nostr";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
@@ -7,6 +7,7 @@ import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import { uploadBytes } from "../../lib/upload"; import { uploadBytes } from "../../lib/upload";
import { getCurrentWindow } from "@tauri-apps/api/window";
export function ArticleEditor() { export function ArticleEditor() {
const { goBack } = useUIStore(); const { goBack } = useUIStore();
@@ -27,6 +28,46 @@ export function ArticleEditor() {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false); 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 // Sync state when active draft changes
useEffect(() => { useEffect(() => {
@@ -46,10 +87,19 @@ export function ArticleEditor() {
if (!activeDraftId) return; if (!activeDraftId) return;
const t = setTimeout(() => { const t = setTimeout(() => {
updateDraft(activeDraftId, { title, content, summary, image, tags }); updateDraft(activeDraftId, { title, content, summary, image, tags });
setLastSaved(Date.now());
}, 1000); }, 1000);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [title, content, summary, image, tags, activeDraftId]); }, [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 = marked(content || "*Nothing to preview yet.*") as string; const renderedHtml = marked(content || "*Nothing to preview yet.*") as string;
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
const canPublish = title.trim().length > 0 && content.trim().length > 0; const canPublish = title.trim().length > 0 && content.trim().length > 0;
@@ -59,7 +109,7 @@ export function ArticleEditor() {
setPublishing(true); setPublishing(true);
setError(null); setError(null);
try { try {
await publishArticle({ const result = await publishArticle({
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
summary: summary.trim() || undefined, summary: summary.trim() || undefined,
@@ -68,7 +118,11 @@ export function ArticleEditor() {
}); });
if (activeDraftId) deleteDraft(activeDraftId); if (activeDraftId) deleteDraft(activeDraftId);
setPublished(true); setPublished(true);
setTimeout(goBack, 1500); setPublishedRelays(result.relayCount);
if (result.relayCount === 0) {
setError("Warning: no relays confirmed — your article may not have been published.");
}
setTimeout(goBack, 2000);
} catch (err) { } catch (err) {
setError(`Failed to publish: ${err}`); setError(`Failed to publish: ${err}`);
} finally { } finally {
@@ -113,6 +167,45 @@ export function ArticleEditor() {
return <DraftListView onNewDraft={handleNewDraft} />; 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 ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
@@ -122,8 +215,13 @@ export function ArticleEditor() {
drafts drafts
</button> </button>
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span> <span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
{activeDraft && !published && ( {activeDraft && !published && lastSaved && (
<span className="text-text-dim text-[10px]">· draft saved</span> <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 && ( {uploading && (
<span className="inline-flex items-center gap-1 text-text-dim text-[10px]"> <span className="inline-flex items-center gap-1 text-text-dim text-[10px]">
@@ -150,6 +248,14 @@ export function ArticleEditor() {
</button> </button>
</div> </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 <button
onClick={() => setShowMeta((v) => !v)} 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"}`} className={`px-3 py-1 text-[11px] border border-border transition-colors ${showMeta ? "text-accent border-accent/40" : "text-text-muted hover:text-text"}`}

View File

@@ -122,7 +122,7 @@ export async function publishArticle(opts: {
summary?: string; summary?: string;
image?: string; image?: string;
tags?: string[]; tags?: string[];
}): Promise<void> { }): Promise<{ relayCount: number }> {
const instance = getNDK(); const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in"); if (!instance.signer) throw new Error("Not logged in");
@@ -144,7 +144,8 @@ export async function publishArticle(opts: {
if (opts.image) event.tags.push(["image", opts.image]); if (opts.image) event.tags.push(["image", opts.image]);
if (opts.tags) opts.tags.forEach((t) => event.tags.push(["t", t])); if (opts.tags) opts.tags.forEach((t) => event.tags.push(["t", t]));
await event.publish(); const relays = await event.publish();
return { relayCount: relays.size };
} }
export async function publishRepost(event: NDKEvent): Promise<void> { export async function publishRepost(event: NDKEvent): Promise<void> {