/* ============================================================
calendar-components.jsx — IntroNote, Tile, SongModal, PreviewBar
============================================================ */
const { useState, useEffect, useRef } = React;
const IS_AUTHOR = !!(window.omelette && window.omelette.writeFile);
Object.assign(window, { IS_AUTHOR });
/* ---------- password gate ---------- */
function Gate({ onUnlock }) {
const [value, setValue] = useState("");
const [error, setError] = useState(false);
const submit = (e) => {
e.preventDefault();
const v = value.trim().toLowerCase();
if (v === "goodmorning") onUnlock(true);
else if (v === "withlove") onUnlock(false);
else { setError(true); setValue(""); }
};
return (
);
}
/* ---------- tiny cassette mark (simple shapes only) ---------- */
function Cassette({ size = 54 }) {
return (
);
}
/* ---------- editable + persisted text ---------- */
function Editable({ value, onChange, className, style, as = "div", placeholder }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current && ref.current.innerText !== value) ref.current.innerText = value;
}, [value]);
const Tag = as;
return (
IS_AUTHOR && onChange(e.currentTarget.innerText)}
/>
);
}
/* ---------- intro note ---------- */
function IntroNote({ title, setTitle, note, setNote, unlocked, opened }) {
return (
);
}
/* ---------- a single day tile — a "window" onto the month's shared picture ---------- */
function Tile({ d, status, onOpen, currentDay }) {
if (d.filler) {
return ;
}
const locked = status === "locked";
const [shake, setShake] = useState(false);
const handleClick = () => {
if (locked) { setShake(true); setTimeout(() => setShake(false), 420); return; }
onOpen(d);
};
return (
);
}
/* ---------- month tab menu ---------- */
function MonthTabs({ groups, activeKey, onSelect, currentDay }) {
return (
);
}
/* ---------- month header above its grid ---------- */
function MonthHead({ group, unlockedIn }) {
const realCount = group.days.filter(d => !d.filler).length;
return (
{group.label}
{unlockedIn}/{realCount}
);
}
/* ---------- the grid, laid over one big shared picture per month ---------- */
function Windowpane({ group, monthKey, statusFor, onOpen, currentDay }) {
const [cols, setCols] = useState(() => colsForWidth(window.innerWidth));
const [editingPhoto, setEditingPhoto] = useState(false);
const photoRef = useRef(null);
useEffect(() => {
const onResize = () => setCols(colsForWidth(window.innerWidth));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
useEffect(() => {
if (photoRef.current) {
photoRef.current.setAttribute("class", "windowpane-photo");
photoRef.current.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;";
}
}, [monthKey]);
const packed = group.kickoff
? group.days.map((d, i) => ({ ...d, gridColumn: `${i + 1} / span 1`, gridRow: "1 / span 1" }))
: window.packRows(group.days, cols);
return (
{packed.map((d, i) => (
))}
);
}
function colsForWidth(w) { return w < 480 ? 4 : w < 700 ? 4 : w < 1000 ? 5 : 6; }
/* Loads the YouTube IFrame API once, shared by every player instance. */
let ytApiPromise = null;
function loadYtApi() {
if (window.YT && window.YT.Player) return Promise.resolve(window.YT);
if (ytApiPromise) return ytApiPromise;
ytApiPromise = new Promise((resolve) => {
const prev = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => { if (prev) prev(); resolve(window.YT); };
const s = document.createElement("script");
s.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(s);
});
return ytApiPromise;
}
/* Embeds via the IFrame API so we can catch "embedding disabled" (error 100/101/150)
and fall back to a tap-through card instead of showing YouTube's broken-player UI. */
function YtPlayer({ id, title }) {
const mountRef = useRef(null);
const playerRef = useRef(null);
const [blocked, setBlocked] = useState(false);
useEffect(() => {
let cancelled = false;
setBlocked(false);
loadYtApi().then((YT) => {
if (cancelled || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
videoId: id,
playerVars: { rel: 0, modestbranding: 1 },
events: {
onError: (e) => { if ([100, 101, 150].includes(e.data)) setBlocked(true); },
},
});
});
return () => { cancelled = true; if (playerRef.current && playerRef.current.destroy) playerRef.current.destroy(); };
}, [id]);
if (blocked) {
return (
this one can't play here — tap below to open it on YouTube
);
}
return ;
}
/* ---------- song reveal modal ---------- */
function SongModal({ d, status, onClose, showNotes }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
}, [onClose]);
if (!d) return null;
const song = d.song;
const search = song
? (song.yt
? `https://music.youtube.com/watch?v=${song.yt}`
: `https://www.youtube.com/results?search_query=${encodeURIComponent(song.title + " " + song.artist)}`)
: null;
return (
e.stopPropagation()}>
DAY {String(d.day).padStart(3, "0")}
{d.dateLong}
{song ? (
{song.yt ? (
) : (
tap below to play on YouTube
)}
TRACK {String(d.day).padStart(3, "0")}
{song.title}
{song.artist}
{showNotes && song.note &&
{song.note}
}
▶ Play on YouTube
) : (
No song set for this day — yet
Add one in calendar-data.jsx → EXAMPLE_SONGS[{d.day}],
with a title, artist, a YouTube id and your note.
)}
);
}
/* ---------- preview / time-travel bar ---------- */
function PreviewBar({ realDay, useReal, setUseReal, simDay, setSimDay }) {
if (!IS_AUTHOR) return null;
const simDate = window.dateForDay(Math.min(Math.max(simDay, 1), window.TOTAL_DAYS));
return (
PREVIEW
{!useReal && (
pretend it's day {simDay} · {window.fmtLong(simDate)}
setSimDay(Number(e.target.value))} />
)}
{useReal && (
{realDay <= 0 ? "the year hasn't started yet — everything's wrapped" : `day ${realDay} of ${window.TOTAL_DAYS} unlocked`}
)}
);
}
Object.assign(window, { Cassette, Editable, IntroNote, Tile, MonthTabs, MonthHead, Windowpane, SongModal, PreviewBar });