// 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}
/>
);
}
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();