// App root — boots, picks active trip + date, renders the editor or onboarding. const todayISO = () => { const dt = new Date(); const y = dt.getFullYear(); const m = String(dt.getMonth() + 1).padStart(2, "0"); const d = String(dt.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; }; const useHashNav = () => { const read = () => { const h = (location.hash || "").replace(/^#\/?/, "").split("/").filter(Boolean); if (h[0] === "trips" && h[1] && h[2] === "days" && h[3]) { return { slug: decodeURIComponent(h[1]), date: h[3] }; } return {}; }; const [loc, setLoc] = React.useState(read()); React.useEffect(() => { const onChange = () => setLoc(read()); window.addEventListener("hashchange", onChange); return () => window.removeEventListener("hashchange", onChange); }, []); const go = (slug, date) => { const h = `#/trips/${encodeURIComponent(slug)}/days/${date}`; if (location.hash !== h) location.hash = h; }; return [loc, go]; }; function App() { const [setup, setSetup] = React.useState(null); const [trips, setTrips] = React.useState(null); const [active, setActive] = React.useState(null); const [theme, setTheme] = React.useState(localStorage.getItem("postcard.theme") || "bone"); const [showTweaks, setShowTweaks] = React.useState(false); const [toast, setToast] = React.useState(""); const [forceNewTrip, setForceNewTrip] = React.useState(false); const [loc, go] = useHashNav(); React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); // iOS Safari ignores `user-scalable=no` in the viewport tag, so we also // cancel its pinch-zoom gestures and the double-tap-zoom shortcut here. React.useEffect(() => { const block = (e) => e.preventDefault(); document.addEventListener("gesturestart", block); document.addEventListener("gesturechange", block); document.addEventListener("gestureend", block); let lastTouchEnd = 0; const onTouchEnd = (e) => { const now = Date.now(); if (now - lastTouchEnd <= 350) e.preventDefault(); lastTouchEnd = now; }; document.addEventListener("touchend", onTouchEnd, { passive: false }); return () => { document.removeEventListener("gesturestart", block); document.removeEventListener("gesturechange", block); document.removeEventListener("gestureend", block); document.removeEventListener("touchend", onTouchEnd); }; }, []); // Pin the html height to the actual visible viewport. 100svh is unreliable // across mobile browsers (especially Arc and some Safari configs with the // URL bar at the bottom), so we measure via visualViewport and expose it // as a CSS custom property. React.useEffect(() => { const vv = window.visualViewport; const apply = () => { const h = vv ? vv.height : window.innerHeight; const offset = vv ? vv.offsetTop : 0; document.documentElement.style.setProperty("--app-h", h + "px"); document.documentElement.style.setProperty("--app-top", offset + "px"); }; apply(); window.addEventListener("resize", apply); window.addEventListener("orientationchange", apply); if (vv) { vv.addEventListener("resize", apply); vv.addEventListener("scroll", apply); } return () => { window.removeEventListener("resize", apply); window.removeEventListener("orientationchange", apply); if (vv) { vv.removeEventListener("resize", apply); vv.removeEventListener("scroll", apply); } }; }, []); React.useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "." ) { e.preventDefault(); setShowTweaks((v) => !v); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const flash = (msg, kind = "") => { setToast({ msg, kind }); setTimeout(() => setToast(""), 3000); }; React.useEffect(() => { (async () => { try { const [s, ts] = await Promise.all([api.getSetup(), api.listTrips()]); setSetup(s); setTrips(ts || []); const act = (ts || []).find((t) => t.is_active) || (ts || [])[0] || null; setActive(act); } catch (e) { /* 401 → /login */ } })(); }, []); const updateTheme = (t) => { setTheme(t); localStorage.setItem("postcard.theme", t); }; if (setup === null || trips === null) { return
loading…
; } if (!setup.github_owner) { return ( <> setSetup(s)} /> ); } if (!active || forceNewTrip) { return ( <> { const ts = await api.listTrips(); setTrips(ts); setActive(trip); setForceNewTrip(false); go(trip.slug, todayISO()); }} /> ); } const slug = (loc.slug && trips.find((t) => t.slug === loc.slug)) ? loc.slug : active.slug; const tripForSlug = trips.find((t) => t.slug === slug) || active; const date = loc.date || todayISO(); return ( <> go(slug, d)} onSwitchTrip={async (t, opts = {}) => { // opts.activate = true forces marking this trip as active (telegram // target). false leaves the active flag alone. Default is false so // clicking a trip in the menu just switches the editor view. if (opts.activate && !t.is_active) { try { await api.activateTrip(t.slug); } catch (e) { flash(e.message, "err"); return; } } const ts = await api.listTrips(); setTrips(ts); setActive(ts.find((x) => x.is_active) || ts.find((x) => x.slug === t.slug) || t); go(t.slug, todayISO()); }} onNewTrip={() => setForceNewTrip(true)} onToast={flash} /> ); } // ---------- EditorShell ---------- function EditorShell({ trip, trips, date, onNavDate, onSwitchTrip, onNewTrip, onToast }) { const [day, setDay] = React.useState(null); const [media, setMedia] = React.useState([]); const [title, setTitle] = React.useState(""); const [body, setBody] = React.useState(""); const [mode, setMode] = React.useState("write"); const [mobileTab, setMobileTab] = React.useState("content"); const [saveState, setSaveState] = React.useState("idle"); // idle | saving | saved const [dirty, setDirty] = React.useState(false); const lastSaveRef = React.useRef(0); const saveTimer = React.useRef(null); const dirtyAfterEdit = React.useRef(false); React.useEffect(() => { let cancelled = false; (async () => { try { const res = await api.getDay(trip.slug, date); if (cancelled) return; setDay(res.day); setMedia(res.media || []); setTitle(res.day.title || ""); setBody(res.day.body || ""); dirtyAfterEdit.current = false; setDirty(false); setSaveState("idle"); } catch (e) { onToast(e.message, "err"); } })(); return () => { cancelled = true; }; }, [trip.slug, date]); React.useEffect(() => { if (!trip || !date) return; return api.subscribeDay(trip.slug, date, { media_added: refreshMedia, media_updated: refreshMedia, media_removed: refreshMedia, media_enriched: refreshMedia, day_updated: refreshAll, }); }, [trip.slug, date]); const refreshMedia = async () => { try { const res = await api.getDay(trip.slug, date); setMedia(res.media || []); } catch {} }; const refreshAll = async () => { try { const res = await api.getDay(trip.slug, date); setDay(res.day); setMedia(res.media || []); } catch {} }; // Debounced autosave on title/body change. React.useEffect(() => { if (!day) return; if (!dirtyAfterEdit.current) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { const patch = {}; if (title !== (day.title || "")) patch.title = title; if (body !== (day.body || "")) patch.body = body; if (Object.keys(patch).length === 0) return; await runSave(patch); }, 450); return () => clearTimeout(saveTimer.current); }, [title, body]); const onMarkDirty = React.useCallback(() => { dirtyAfterEdit.current = true; setDirty(true); }, []); const runSave = async (patch) => { setSaveState("saving"); const t = Date.now(); lastSaveRef.current = t; try { const updated = await api.patchDay(trip.slug, date, patch); if (lastSaveRef.current === t) { setDay(updated); setSaveState("saved"); if (updated.state !== "posted-edited" && updated.state !== "posted") { // pure draft — saved means not dirty for publish purposes setDirty(false); } } } catch (e) { onToast(e.message, "err"); setSaveState("idle"); } }; const inPost = media.filter((m) => m.status === "featured" || m.status === "used"); const counts = { all: media.length, inPost: inPost.length, featured: media.filter((m) => m.status === "featured").length, used: media.filter((m) => m.status === "used").length, context: media.filter((m) => m.status === "context").length, hidden: media.filter((m) => m.status === "hidden").length, }; const wordCount = (body || "").trim().split(/\s+/).filter(Boolean).length; // Status pill computation. The server tracks state ∈ {draft, posted, // posted-edited}. Combine with local saving state for the pill. An // `is_draft` flag overrides — it's an intentional "don't publish". const status = (() => { if (!day) return { kind: "", label: "Loading…" }; if (saveState === "saving") return { kind: "saving", label: "Saving…" }; if (day.is_draft) return { kind: "edited", label: "Private draft" }; if (day.state === "posted") return { kind: "published", label: "Published" }; if (day.state === "posted-edited") return { kind: "edited", label: "Published · edited" }; return { kind: "", label: "Draft" }; })(); // "dirty" for the publish button: private-draft flag or an in-progress // draft / edited-posted. const publishDirty = day && (day.is_draft || day.state === "draft" || day.state === "posted-edited"); const publishedAt = day && day.posted_at ? prettyAgo(day.posted_at) : ""; const [filter, setFilter] = React.useState("all"); const [selectedItem, setSelectedItem] = React.useState(null); const visibleItems = React.useMemo(() => { if (filter === "all") return media; if (filter === "in-post") return media.filter((m) => m.status === "featured" || m.status === "used"); return media.filter((m) => m.status === filter); }, [media, filter]); const setItemStatus = async (id, status) => { setMedia((ms) => ms.map((m) => m.id === id ? { ...m, status } : m)); try { await api.patchMedia(id, { status }); } catch (e) { onToast(e.message, "err"); refreshMedia(); } }; const deleteItem = async (id) => { // Optimistic removal — the SSE `media_removed` event would refresh // anyway but we don't want a visible lag. const prev = media; setMedia((ms) => ms.filter((m) => m.id !== id)); try { await api.deleteMedia(id); } catch (e) { onToast(e.message, "err"); setMedia(prev); } }; const moveItemToYesterday = async (id) => { const target = addDays(date, -1); const prev = media; setMedia((ms) => ms.filter((m) => m.id !== id)); try { await api.patchMedia(id, { day: target }); onToast("Moved to " + target); } catch (e) { onToast(e.message, "err"); setMedia(prev); } }; const onPublish = async () => { setSaveState("saving"); try { const res = await api.publishDay(trip.slug, date); onToast("Published · " + res.commit_sha.slice(0, 7)); await refreshAll(); setDirty(false); } catch (e) { onToast(e.message, "err"); } finally { setSaveState("idle"); } }; const onApplyDraft = (text) => { setBody(text); onMarkDirty(); }; return (
runSave({ is_draft: next })} onDate={onNavDate} onSwitchTrip={onSwitchTrip} onNewTrip={onNewTrip} onPublish={onPublish} />
{day ? ( ) : (
loading…
)}
); } function MobileTabs({ active, setActive, counts }) { const tabs = [ { k: "assets", l: "Assets", badge: counts.all }, { k: "content", l: "Content" }, { k: "heron", l: "Heron" }, ]; return ( ); } function prettyAgo(unix) { if (!unix) return ""; const diff = Math.floor(Date.now() / 1000 - unix); if (diff < 60) return "just now"; if (diff < 3600) return Math.floor(diff / 60) + " minute" + (diff < 120 ? "" : "s") + " ago"; if (diff < 86400) return Math.floor(diff / 3600) + " hour" + (diff < 7200 ? "" : "s") + " ago"; return Math.floor(diff / 86400) + " day" + (diff < 172800 ? "" : "s") + " ago"; } ReactDOM.createRoot(document.getElementById("root")).render();