import { useState, useEffect, useRef } from "react";
const MCP = "https://mcp.notion.com/mcp";
const DB = "https://www.notion.so/34340e02c2c680d5926ff95582480703";
const fmt = d => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
const iso = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
async function claudeCall(system, user, tokens = 4000, noMcp = false) {
const body = {
model: "claude-sonnet-4-20250514",
max_tokens: tokens,
system,
messages: [{ role: "user", content: user }],
};
if (!noMcp) body.mcp_servers = [{ type: "url", url: MCP, name: "notion" }];
const r = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
const AUTO_FIELDS = ["good_day", "rough_day", "watch_for", "community_prompt", "notification_text", "art_recs", "speaker_guide_line"];
async function generateMissing(p) {
const missing = AUTO_FIELDS.filter(k => !p[k] || !p[k].trim());
if (missing.length === 0) return {};
const systemPrompt = `You are a content writer for Our Daily Quilt (ODQ). Given a quote and author, generate content for missing fields. Return ONLY a JSON object with the requested fields.
Field specs:
- good_day: Short declarative push, has edge, specific enough to act on. Sometimes a command. No questions, no filler. "Today" only when it earns it.
- rough_day: Reframes without naming emotions or assuming how someone feels. No demands. Strips to essential permission or redirect. Can be as short as three words. Never diagnoses.
- watch_for: A standalone sentence fragment naming a specific observable behavior. UI prepends "Watch for the moment today when..." so the value continues from that. No adverbs doing interpretive work.
- community_prompt: Single question inviting users to share something from their experience useful to others. Transferable. Plain language. Does not mention quote or author. Ends with ?
- notification_text: Format "[Full Name] on [what the quote is about]". The on... part intriguing and human. No period. One line.
- art_recs: 5 recommendations across music, film, painting, literature, and one wildcard. Format: Title, Artist — one sentence why it connects. All 5 in one field.
- speaker_guide_line: 1 sentence about who this person was and why their perspective matters. Start with a verb, omit name at start. Grounded in what they actually lived. No reverence.`;
const res = await claudeCall(
systemPrompt,
`Quote: "${p.Quote}" — ${p.author}\n\nGenerate ONLY these missing fields as JSON: ${missing.join(", ")}`,
2000,
true
);
for (const b of (res.content || [])) {
if (b.type === "text") {
const m = b.text.replace(/```json|```/g, "").match(/\{[\s\S]*\}/);
if (m) { try { return JSON.parse(m[0]); } catch(e) {} }
}
}
return {};
}
function parseJSON(data) {
for (const b of (data.content || [])) {
if (b.type === "text") {
const m = b.text.replace(/```json|```/g, '').match(/\{[\s\S]*\}/);
if (m) { try { return JSON.parse(m[0]); } catch(e) {} }
}
}
return null;
}
const WARM = {
bg: "#F5F0E8",
surface: "#FDFAF4",
surface2: "#EDE8DC",
border: "rgba(120,100,60,0.15)",
borderStrong: "rgba(120,100,60,0.28)",
text: "#2C2416",
text2: "#7A6A50",
text3: "#B0A080",
accent: "#3D2F1A",
accentFg: "#FDFAF4",
blue: "#5B7FA6",
green: "#4A8C6A",
amber: "#A07030",
red: "#A04040",
sectionBg: "rgba(120,100,60,0.06)",
};
const inputStyle = {
width: "100%",
background: "none",
border: "none",
outline: "none",
fontSize: 14,
color: WARM.text,
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
lineHeight: 1.7,
resize: "none",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "break-word",
};
function Field({ label, children, changed, fullWidth, mono }) {
return (
<div style={{
background: WARM.surface,
border: `1px solid ${changed ? WARM.blue : WARM.border}`,
borderRadius: 8,
padding: "10px 14px",
gridColumn: fullWidth ? "1/-1" : undefined,
transition: "border-color 0.15s",
boxShadow: "0 1px 3px rgba(80,60,20,0.06)",
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
<span style={{
fontSize: 9,
fontWeight: 600,
color: WARM.text3,
textTransform: "uppercase",
letterSpacing: "0.1em",
fontFamily: "'DM Mono', monospace",
}}>{label}</span>
{changed && <div style={{ width: 5, height: 5, borderRadius: "50%", background: WARM.blue, marginLeft: "auto", flexShrink: 0 }} />}
</div>
{children}
</div>
);
}
function SectionHeader({ title }) {
return (
<div style={{
display: "flex",
alignItems: "center",
gap: 10,
margin: "24px 0 12px",
}}>
<span style={{
fontSize: 9,
fontWeight: 700,
color: WARM.text3,
textTransform: "uppercase",
letterSpacing: "0.14em",
fontFamily: "'DM Mono', monospace",
flexShrink: 0,
}}>{title}</span>
<div style={{ flex: 1, height: "0.5px", background: WARM.borderStrong, opacity: 0.5 }} />
</div>
);
}
function ImagePreview({ url }) {
const [state, setState] = useState("idle");
const prevUrl = useRef(null);
useEffect(() => {
if (url !== prevUrl.current) {
prevUrl.current = url;
setState(url ? "loading" : "idle");
}
}, [url]);
if (!url) return null;
return (
<div style={{ marginTop: 10, borderRadius: 6, overflow: "hidden", border: `1px solid ${WARM.border}`, background: WARM.surface2 }}>
{state === "error" ? (
<div style={{ padding: "10px 14px", fontSize: 13, color: WARM.text2, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: WARM.text3 }}>Can't embed image —</span>
<a href={url} target="_blank" rel="noreferrer" style={{ color: WARM.blue, textDecoration: "none" }}>Open image ↗</a>
</div>
) : (
<>
<img
src={url}
alt="Speaker portrait"
onLoad={() => setState("loaded")}
onError={() => setState("error")}
style={{ width: "100%", maxHeight: 180, objectFit: "cover", objectPosition: "top", display: state === "loaded" ? "block" : "none" }}
/>
{state === "loading" && (
<div style={{ padding: "10px 14px", fontSize: 13, color: WARM.text3 }}>Loading image…</div>
)}
</>
)}
</div>
);
}
export default function App() {
const tomorrow = () => { const d = new Date(); d.setDate(d.getDate() + 1); return d; };
const [date, setDate] = useState(tomorrow());
const [status, setStatus] = useState({ state: "idle", msg: "Ready" });
const [data, setData] = useState(null);
const [orig, setOrig] = useState(null);
const [changed, setChanged] = useState({});
const [saving, setSaving] = useState(false);
const pageId = useRef(null);
const pageUrl = useRef(null);
function shiftDate(d) {
setDate(prev => { const n = new Date(prev); n.setDate(n.getDate() + d); return n; });
setChanged({});
}
useEffect(() => { load(); }, [date]);
async function load() {
setStatus({ state: "loading", msg: "Fetching…" });
setData(null); setOrig(null); setChanged({});
try {
const res = await claudeCall(
`Query ODQ QUOTES DATABASE (${DB}). Find page where date_scheduled="${iso(date)}". Return ONLY valid JSON with these exact keys: page_id, page_url, Quote, author, speaker_dates, keyword, notification_text, good_day, rough_day, community_prompt, first_response, speaker_image_url, image_attribution, speaker_guide_line, watch_for, art_recs, art_recs_type, approved, reviewed. For all rich text / text properties return the full complete string with no truncation. Empty string for missing text fields, null for missing select fields. If page not found return {"error":"not_found"}.`,
`Get the full content of the ODQ quote page scheduled for ${iso(date)}.`
);
const p = parseJSON(res);
if (!p || p.error) { setStatus({ state: "", msg: "" }); setData("empty"); return; }
pageId.current = p.page_id;
pageUrl.current = p.page_url;
// Auto-generate missing fields
const hasMissing = AUTO_FIELDS.some(k => !p[k] || !p[k].trim());
if (hasMissing) {
setOrig(p); setData({ ...p });
setStatus({ state: "loading", msg: "Generating missing fields…" });
try {
const generated = await generateMissing(p);
const merged = { ...p, ...generated };
const autoChanged = {};
for (const k of Object.keys(generated)) autoChanged[k] = true;
setOrig(p);
setData(merged);
setChanged(autoChanged);
setStatus({ state: "success", msg: `Loaded · ${Object.keys(generated).length} fields generated` });
} catch(e) {
setStatus({ state: "success", msg: "Loaded (generation failed)" });
}
} else {
setOrig(p); setData({ ...p });
setStatus({ state: "success", msg: "Loaded" });
}
} catch(e) {
setStatus({ state: "error", msg: e.message });
setData("error");
}
}
function update(key, val) {
setData(d => ({ ...d, [key]: val }));
setChanged(c => ({ ...c, [key]: val !== (orig?.[key] || '') }));
}
const changedKeys = Object.keys(changed).filter(k => changed[k]);
const hasChanges = changedKeys.length > 0;
function reset() { setData({ ...orig }); setChanged({}); }
async function save() {
if (!pageId.current || !hasChanges) return;
setSaving(true); setStatus({ state: "loading", msg: "Saving…" });
const updates = {};
for (const k of changedKeys) updates[k] = data[k] || null;
try {
await claudeCall(
`Update Notion page "${pageId.current}" with these properties: ${JSON.stringify(updates)}. Property types: keyword/notification_text/good_day/rough_day/community_prompt/first_response/speaker_image_url/image_attribution/speaker_guide_line/speaker_dates/watch_for/art_recs=text, art_recs_type/approved=select, "reviewed?"=checkbox(true if value is "__YES__"). Return {"success":true}.`,
"Update the page.", 1000
);
setOrig({ ...data });
setChanged({});
setStatus({ state: "success", msg: "Saved" });
} catch(e) {
setStatus({ state: "error", msg: "Save failed" });
}
setSaving(false);
}
const dotColor = { loading: WARM.amber, success: WARM.green, error: WARM.red }[status.state] || WARM.text3;
const ta = (k, rows = 3) => (
<textarea
rows={rows}
value={data[k] || ""}
onChange={e => update(k, e.target.value)}
style={{ ...inputStyle, minHeight: rows * 24 }}
/>
);
const inp = (k) => (
<input
type="text"
value={data[k] || ""}
onChange={e => update(k, e.target.value)}
style={{ ...inputStyle, display: "block" }}
/>
);
return (
<div style={{ background: WARM.bg, minHeight: "100vh", fontFamily: "Inter, system-ui, sans-serif" }}>
{/* Header */}
<div style={{
position: "sticky", top: 0, zIndex: 10,
background: WARM.bg,
borderBottom: `1px solid ${WARM.border}`,
padding: "0 24px",
height: 50,
display: "flex", alignItems: "center", gap: 12,
}}>
<span style={{ fontSize: 13, fontWeight: 600, color: WARM.text2, flex: 1, fontFamily: "'DM Mono', monospace", letterSpacing: "0.06em" }}>ODQ · EDITOR</span>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{["‹","›"].map((ch, i) => (
<button key={ch} onClick={() => shiftDate(i === 0 ? -1 : 1)} style={{ width: 26, height: 26, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${WARM.border}`, borderRadius: 6, background: WARM.surface, color: WARM.text2, cursor: "pointer", fontSize: 16, lineHeight: 1 }}>{ch}</button>
))}
<span style={{ fontSize: 13, fontWeight: 500, color: WARM.text, background: WARM.surface, border: `1px solid ${WARM.borderStrong}`, borderRadius: 6, padding: "3px 10px", minWidth: 110, textAlign: "center", fontFamily: "'DM Mono', monospace" }}>{fmt(date)}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: WARM.text3, fontFamily: "'DM Mono', monospace" }}>
<div style={{ width: 6, height: 6, borderRadius: "50%", background: dotColor, animation: status.state === "loading" ? "pulse 1s infinite" : "none", flexShrink: 0 }} />
{status.msg}
</div>
</div>
<div style={{ maxWidth: 720, margin: "0 auto", padding: "0 24px 80px" }}>
{(!data || data === "empty" || data === "error") && (
<div style={{ textAlign: "center", padding: "5rem 1rem", color: WARM.text3, fontSize: 14, lineHeight: 2 }}>
{!data ? "Loading…" : data === "empty" ? `No quote scheduled for ${fmt(date)}.` : `Couldn't load. ${status.msg}`}
</div>
)}
{data && data !== "empty" && data !== "error" && (<>
{/* Quote hero — sticky */}
<div style={{ position: "sticky", top: 50, zIndex: 9, background: WARM.bg, paddingTop: 16, paddingBottom: 8 }}>
<div style={{ background: WARM.surface, border: `1px solid ${WARM.border}`, borderRadius: 10, padding: "16px 20px", boxShadow: "0 2px 8px rgba(80,60,20,0.08)", display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
<div>
<div style={{ fontSize: 16, fontStyle: "italic", lineHeight: 1.6, color: WARM.text, marginBottom: 6 }}>"{data.Quote}"</div>
<div style={{ fontSize: 12, color: WARM.text2, fontFamily: "'DM Mono', monospace" }}>— {data.author}{data.speaker_dates ? ` · ${data.speaker_dates}` : ""}</div>
</div>
{pageUrl.current && (
<a href={pageUrl.current} target="_blank" rel="noreferrer" style={{ flexShrink: 0, width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${WARM.border}`, borderRadius: 6, color: WARM.text3, textDecoration: "none", fontSize: 14, background: WARM.surface2 }}>↗</a>
)}
</div>
</div>
{/* GETTING STARTED */}
<SectionHeader title="Getting Started" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 8 }}>
<Field label="Keyword" changed={changed.keyword}>{inp("keyword")}</Field>
<Field label="Notification text" changed={changed.notification_text} fullWidth>{ta("notification_text", 2)}</Field>
<Field label="Good day" changed={changed.good_day}>{ta("good_day", 2)}</Field>
<Field label="Rough day" changed={changed.rough_day}>{ta("rough_day", 2)}</Field>
</div>
{/* COMMUNITY REFLECTION */}
<SectionHeader title="Community Reflection" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 8 }}>
<Field label="Community prompt" changed={changed.community_prompt} fullWidth>{ta("community_prompt", 2)}</Field>
<Field label="First response" changed={changed.first_response} fullWidth>{ta("first_response", 3)}</Field>
</div>
{/* SPEAKER CARD */}
<SectionHeader title="Speaker Card" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 8 }}>
<Field label="Speaker image URL" changed={changed.speaker_image_url} fullWidth>
{inp("speaker_image_url")}
{data.speaker_image_url && (
<a href={data.speaker_image_url} target="_blank" rel="noreferrer" style={{ display: "inline-block", marginTop: 8, fontSize: 12, color: WARM.blue, textDecoration: "none", border: `1px solid ${WARM.blue}`, borderRadius: 5, padding: "4px 10px" }}>View image ↗</a>
)}
</Field>
<Field label="Attribution" changed={changed.image_attribution}>{inp("image_attribution")}</Field>
<Field label="Speaker dates" changed={changed.speaker_dates}>{inp("speaker_dates")}</Field>
<Field label="Speaker guide line" changed={changed.speaker_guide_line} fullWidth>{ta("speaker_guide_line", 2)}</Field>
</div>
{/* BEFORE YOU GO */}
<SectionHeader title="Before You Go" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 8 }}>
<Field label="Watch for" changed={changed.watch_for} fullWidth>{ta("watch_for", 2)}</Field>
<Field label="Art recs" changed={changed.art_recs} fullWidth>{ta("art_recs", 3)}</Field>
<Field label="Art type" changed={changed.art_recs_type}>
<select value={data.art_recs_type || ""} onChange={e => update("art_recs_type", e.target.value)} style={{ ...inputStyle, cursor: "pointer", display: "block" }}>
{["","music","book","movie","art","other"].map(v => <option key={v} value={v}>{v || "—"}</option>)}
</select>
</Field>
</div>
{/* Review */}
<SectionHeader title="Review" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 8 }}>
<Field label="Approved" changed={changed.approved}>
<select value={data.approved || ""} onChange={e => update("approved", e.target.value)} style={{ ...inputStyle, cursor: "pointer", display: "block" }}>
{["","TRUE","FALSE"].map(v => <option key={v} value={v}>{v || "—"}</option>)}
</select>
</Field>
<Field label="Reviewed" changed={changed.reviewed}>
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 2 }}>
<input type="checkbox" id="reviewed" checked={data.reviewed === "__YES__" || data.reviewed === true} onChange={e => update("reviewed", e.target.checked ? "__YES__" : "")} style={{ width: 15, height: 15, cursor: "pointer", accentColor: WARM.accent }} />
<label htmlFor="reviewed" style={{ fontSize: 14, color: WARM.text2, cursor: "pointer", fontFamily: "Inter, system-ui, sans-serif" }}>Yes</label>
</div>
</Field>
</div>
</>)}
</div>
{/* Sticky action bar */}
{data && data !== "empty" && data !== "error" && (
<div style={{ position: "fixed", bottom: 0, left: 0, right: 0, background: WARM.bg, borderTop: `1px solid ${WARM.border}`, padding: "12px 24px", display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={save} disabled={saving || !hasChanges} style={{ background: hasChanges ? WARM.accent : WARM.text3, color: WARM.accentFg, border: "none", borderRadius: 6, padding: "8px 18px", fontSize: 14, fontWeight: 500, cursor: hasChanges ? "pointer" : "not-allowed", opacity: hasChanges ? 1 : 0.5, fontFamily: "Inter, system-ui, sans-serif", transition: "opacity 0.15s" }}>
✓ Save to Notion
</button>
<button onClick={reset} style={{ background: "none", border: `1px solid ${WARM.border}`, borderRadius: 6, padding: "7px 14px", fontSize: 14, color: WARM.text2, cursor: "pointer", fontFamily: "Inter, system-ui, sans-serif" }}>Reset</button>
{hasChanges && <span style={{ fontSize: 11, color: WARM.text3, marginLeft: "auto", fontFamily: "'DM Mono', monospace" }}>{changedKeys.length} unsaved</span>}
</div>
)}
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } * { box-sizing: border-box; } textarea { overflow: hidden; } textarea:focus, input:focus, select:focus { outline: none; }`}</style>
</div>
);
}