// app — dashboard for creating and managing your cards.
// Single user. List of cards on the left, create form on the right with a
// live FlippableCard preview. Each row shows the share URL with a copy button.
function fmtAgo(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) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
return Math.floor(diff / 86400) + "d ago";
}
function fmtCardDate(unix) {
if (!unix) return "";
return new Date(unix * 1000).toLocaleDateString(undefined, {
day: "numeric", month: "long",
});
}
function postmarkDate(unix) {
if (!unix) unix = Math.floor(Date.now() / 1000);
const d = new Date(unix * 1000);
const day = String(d.getDate()).padStart(2, "0");
const mon = d.toLocaleString(undefined, { month: "short" }).toUpperCase();
const yr = String(d.getFullYear()).slice(-2);
return `${day} · ${mon} · ${yr}`;
}
function Toast({ msg, kind }) {
if (!msg) return null;
return
{msg}
;
}
function CopyButton({ value }) {
const [copied, setCopied] = React.useState(false);
const onClick = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {}
};
return (
{copied ? "copied" : "copy"}
);
}
// Keep in sync with cardgen.AllStampArts in Go.
const ALL_STAMP_ARTS = [
"grid", "rings", "starburst", "stripes", "dots",
"cross", "diamond", "waves", "chevron", "triangles", "block",
];
// Keep in sync with allowedTZ in handlers/cards.go.
const TZ_OPTIONS = [
{ label: "Melbourne", value: "Australia/Melbourne" },
{ label: "Beijing", value: "Asia/Shanghai" },
{ label: "Tokyo", value: "Asia/Tokyo" },
];
// LivePreview shows a flippable card whose back is the actual server-rendered
// PNG (fetched from /api/preview). Front is the user's chosen photo via a
// client-side blob URL. Tap to flip.
function LivePreview({ frontUrl, backUrl }) {
const [flipped, setFlipped] = React.useState(false);
return (
setFlipped((f) => !f)}
role="button"
aria-label="Tap to flip"
>
{frontUrl ?
:
No photo yet
}
{backUrl ?
:
Loading preview…
}
Tap to flip
);
}
function CardRow({ card, onDelete }) {
return (
{card.recipient_name}
{card.city} · {card.country}
{card.view_count} view{card.view_count === 1 ? "" : "s"}
{card.last_viewed_at && · last {fmtAgo(card.last_viewed_at)} }
e.target.select()} />
onDelete(card.slug)} title="Delete">
×
);
}
function CreatePanel({ onCreated, flash, latest }) {
// Fields default to the most recent card so the working state survives a
// refresh (or a device switch — the data lives in the DB, not localStorage).
// Recipient is part of that prefill: typical flow is "same card, different
// recipient", so the user just edits the name and re-submits.
const [photo, setPhoto] = React.useState(null);
const [photoUrl, setPhotoUrl] = React.useState("");
const [orientation, setOrientation] = React.useState("landscape");
const [recipientName, setRecipientName] = React.useState(latest?.recipient_name || "");
const [message, setMessage] = React.useState(latest?.message || "");
const [city, setCity] = React.useState(latest?.city || "");
const [country, setCountry] = React.useState(latest?.country || "");
const [countryCode, setCountryCode] = React.useState(latest?.country_code || "");
const [stampArt, setStampArt] = React.useState(latest?.stamp_art || "grid");
const [timezone, setTimezone] = React.useState(latest?.timezone || "Australia/Melbourne");
const [submitting, setSubmitting] = React.useState(false);
const [previewUrl, setPreviewUrl] = React.useState("");
// If `latest` arrives after the first render (cards list is fetched async),
// and the user hasn't typed anything yet, fill the empty fields. Don't
// clobber values the user has already entered.
const seededRef = React.useRef(false);
React.useEffect(() => {
if (!latest || seededRef.current) return;
if (!recipientName) setRecipientName(latest.recipient_name || "");
if (!message) setMessage(latest.message || "");
if (!city) setCity(latest.city || "");
if (!country) setCountry(latest.country || "");
if (!countryCode) setCountryCode(latest.country_code || "");
if (latest.stamp_art && stampArt === "grid") setStampArt(latest.stamp_art);
if (latest.timezone && timezone === "Australia/Melbourne") setTimezone(latest.timezone);
seededRef.current = true;
}, [latest]);
// Live server-side preview — debounced fetch of /api/preview, draws the
// exact PNG that would land on disk at create time. Replaces the React
// mock that used to drift from the real renderer.
React.useEffect(() => {
const t = setTimeout(async () => {
const fd = new FormData();
fd.append("recipient_name", recipientName);
fd.append("message", message);
fd.append("city", city);
fd.append("country", country);
if (countryCode.trim()) fd.append("country_code", countryCode.trim().toUpperCase());
fd.append("stamp_art", stampArt);
fd.append("timezone", timezone);
try {
const blob = await api.previewCard(fd);
const url = URL.createObjectURL(blob);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
} catch { /* ignore — keep last good preview */ }
}, 300);
return () => clearTimeout(t);
}, [recipientName, message, city, country, countryCode, stampArt, timezone]);
React.useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
}, []);
// Mirror the server's portrait→landscape 90° CCW rotation client-side so
// the preview matches the eventual rendered card. Without this, portrait
// photos in a landscape preview container get cropped by object-fit cover.
React.useEffect(() => {
if (!photo) { setPhotoUrl(""); return; }
let cancelled = false;
let blobUrl = null;
const probe = URL.createObjectURL(photo);
const img = new Image();
img.onload = () => {
if (cancelled) return;
if (img.naturalHeight > img.naturalWidth) {
setOrientation("portrait");
const canvas = document.createElement("canvas");
canvas.width = img.naturalHeight;
canvas.height = img.naturalWidth;
const ctx = canvas.getContext("2d");
ctx.translate(0, img.naturalWidth);
ctx.rotate(-Math.PI / 2);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (cancelled || !blob) return;
blobUrl = URL.createObjectURL(blob);
setPhotoUrl(blobUrl);
URL.revokeObjectURL(probe);
}, "image/jpeg", 0.9);
} else {
setOrientation("landscape");
blobUrl = probe;
setPhotoUrl(probe);
}
};
img.onerror = () => { URL.revokeObjectURL(probe); };
img.src = probe;
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
else URL.revokeObjectURL(probe);
};
}, [photo]);
const onSubmit = async (e) => {
e.preventDefault();
if (submitting) return;
if (!photo) { flash("Pick a photo first.", "err"); return; }
if (!recipientName.trim() || !message.trim() || !city.trim() || !country.trim()) {
flash("Fill in recipient, message, city, country.", "err");
return;
}
setSubmitting(true);
try {
const fd = new FormData();
fd.append("photo", photo, photo.name);
fd.append("recipient_name", recipientName.trim());
fd.append("message", message);
fd.append("city", city.trim());
fd.append("country", country.trim());
if (countryCode.trim()) fd.append("country_code", countryCode.trim().toUpperCase());
fd.append("stamp_art", stampArt);
fd.append("timezone", timezone);
const card = await api.createCard(fd);
try { await navigator.clipboard.writeText(card.share_url); flash("Card created · URL copied"); }
catch { flash("Card created"); }
// Don't reset — fields stay populated so you can fire another card to
// a different recipient with the same photo / message / location. Edit
// whatever needs editing and submit again.
onCreated(card);
} catch (err) {
flash(err.message || "couldn't create", "err");
} finally {
setSubmitting(false);
}
};
// For preview only — the server picks defaults if country_code is set.
// We mirror the same lookup very loosely on the client just for UX.
const previewMessage = (message || "")
.split(/\r?\n/)
.map((s) => s.trimEnd());
return (
);
}
function App() {
const [authChecked, setAuthChecked] = React.useState(false);
const [cards, setCards] = React.useState([]);
const [toast, setToast] = React.useState({ msg: "", kind: "" });
const flash = (msg, kind = "") => {
setToast({ msg, kind });
setTimeout(() => setToast({ msg: "", kind: "" }), 2600);
};
const refresh = React.useCallback(async () => {
try {
const r = await api.listCards();
setCards(r.cards || []);
} catch (e) { /* 401 redirects */ }
}, []);
React.useEffect(() => {
(async () => {
try {
const s = await api.authStatus();
if (!s.has_owner) { location.href = "/login"; return; }
if (!s.authed) { location.href = "/login"; return; }
await refresh();
setAuthChecked(true);
} catch (e) { /* 401 redirects */ }
})();
}, [refresh]);
// Refetch when the user returns to this tab — view counts on the public
// viewer don't push back, so we pull on focus.
React.useEffect(() => {
const onVis = () => { if (!document.hidden) refresh(); };
document.addEventListener("visibilitychange", onVis);
window.addEventListener("focus", refresh);
return () => {
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("focus", refresh);
};
}, [refresh]);
const onCreated = (card) => {
setCards((prev) => [card, ...prev]);
};
const onDelete = async (slug) => {
if (!confirm("Delete this postcard? The link will stop working.")) return;
try {
await api.deleteCard(slug);
setCards((prev) => prev.filter((c) => c.slug !== slug));
flash("Deleted");
} catch (e) {
flash(e.message || "couldn't delete", "err");
}
};
const onLogout = async () => {
try { await api.logout(); } catch {}
location.href = "/login";
};
if (!authChecked) {
return loading…
;
}
return (
Sent ({cards.length})
{cards.length === 0 ? (
No postcards yet. Create one above.
) : (
{cards.map((c) => (
))}
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );