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