corner_cafe / script.js
2000pd3rvr
Corner cafe v1.17: cycle the full hero set on every device.
4937c1c
Raw
History Blame Contribute Delete
34.9 kB
(() => {
const THEME_KEY = "corner-cafe-theme";
const applyChrome = (theme) => {
const meta = document.getElementById("meta-theme-color");
if (meta) {
meta.setAttribute("content", theme === "light" ? "#f6f3ee" : "#070707");
}
};
const syncToggle = () => {
const th = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (!toggle) return;
const goLight = th === "dark";
toggle.setAttribute("aria-label", goLight ? "Switch to light theme" : "Switch to dark theme");
toggle.setAttribute("title", goLight ? "Light theme" : "Dark theme");
};
const setTheme = (theme) => {
const th = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", th);
try {
localStorage.setItem(THEME_KEY, th);
} catch (_) {}
applyChrome(th);
syncToggle();
};
let stored = null;
try {
stored = localStorage.getItem(THEME_KEY);
} catch (_) {}
if (stored === "light" || stored === "dark") {
setTheme(stored);
} else {
applyChrome(document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark");
syncToggle();
}
document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
setTheme(cur === "dark" ? "light" : "dark");
});
const navToggle = document.querySelector("[data-nav-toggle]");
const navPanel = document.querySelector("[data-nav-panel]");
if (navToggle && navPanel) {
navToggle.addEventListener("click", () => {
const open = navPanel.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
});
navPanel.querySelectorAll("a").forEach((el) => {
el.addEventListener("click", () => {
navPanel.classList.remove("is-open");
navToggle.setAttribute("aria-expanded", "false");
});
});
}
document.querySelectorAll("[data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
const root = btn.closest("[data-tabs]");
if (!root || !tab) return;
root.querySelectorAll("[data-tab]").forEach((b) =>
b.classList.toggle("is-active", b.dataset.tab === tab)
);
root.querySelectorAll("[data-panel]").forEach((panel) => {
panel.hidden = panel.dataset.panel !== tab;
});
});
});
const y = document.querySelector("#year");
if (y) y.textContent = String(new Date().getFullYear());
const openNowEls = Array.from(document.querySelectorAll("[data-open-now]"));
if (openNowEls.length) {
const toMinutes = (hh, mm) => hh * 60 + mm;
const DAY_ABBR = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const fmtTime = (mins) => {
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
const daySlots = (day) => {
// Every day Mon–Sun 09:00–17:00
void day;
return [toMinutes(9, 0)];
};
const isOpenNow = (d) => {
const mins = toMinutes(d.getHours(), d.getMinutes());
// Monday–Sunday: 09:00–17:00
return mins >= toMinutes(9, 0) && mins < toMinutes(17, 0);
};
const nextOpenText = (d) => {
const today = d.getDay();
const nowMins = toMinutes(d.getHours(), d.getMinutes());
for (let offset = 0; offset < 7; offset += 1) {
const day = (today + offset) % 7;
const starts = daySlots(day);
if (!starts.length) continue;
const candidate = offset === 0 ? starts.find((m) => m > nowMins) : starts[0];
if (candidate == null) continue;
if (offset === 0) return fmtTime(candidate);
return `${DAY_ABBR[day]} ${fmtTime(candidate)}`;
}
return "";
};
const syncOpenNow = () => {
const open = isOpenNow(new Date());
const opensAt = nextOpenText(new Date());
openNowEls.forEach((el) => {
el.textContent = open ? "We are open" : `Opens at ${opensAt}`;
el.classList.toggle("is-open", open);
el.classList.toggle("is-closed", !open);
});
};
syncOpenNow();
const openNowTimer = window.setInterval(syncOpenNow, 30000);
window.addEventListener(
"pagehide",
() => {
window.clearInterval(openNowTimer);
},
{ once: true }
);
}
const hoursQuote = document.querySelector("[data-hours-quote]");
if (hoursQuote) {
const quotes = [
"“A cracking wee cafe—proper Scottish breakfast and tea that tastes like home.”",
"“Cullen skink, warm scones and a pot of tea—exactly what you want at Eskdail Court.”",
"“Haggis, neeps and tatties done right; the cream tea is not to be missed.”",
];
let quoteIndex = 0;
let cycleTimer = 0;
const QUOTE_MS = 5200;
if (quotes.length > 1) {
const playQuote = (idx) => {
hoursQuote.classList.remove("is-typing");
hoursQuote.textContent = quotes[idx];
void hoursQuote.offsetWidth;
hoursQuote.classList.add("is-typing");
};
playQuote(quoteIndex);
cycleTimer = window.setInterval(() => {
if (document.hidden) return;
quoteIndex = (quoteIndex + 1) % quotes.length;
playQuote(quoteIndex);
}, QUOTE_MS);
window.addEventListener(
"pagehide",
() => {
window.clearInterval(cycleTimer);
},
{ once: true }
);
}
}
/* Metered / slow connections still affect spotlight density; the hero always cycles
the full set and only downloads one clip ahead. */
const connection =
navigator.connection || navigator.mozConnection || navigator.webkitConnection || null;
const lightMediaMode = !!(
connection &&
(connection.saveData || /^(slow-2g|2g|3g)$/.test(connection.effectiveType || ""))
);
/**
* Give a lazily declared video its real source the first time that clip is needed.
* Returns true when it attached (and therefore already started loading).
*/
const attachVideoSource = (vid) => {
if (!vid || vid.tagName !== "VIDEO" || vid.dataset.srcAttached === "1") return false;
const url = vid.dataset.src;
if (!url) return false;
const source = document.createElement("source");
source.src = url;
source.type = "video/mp4";
vid.appendChild(source);
vid.dataset.srcAttached = "1";
vid.load();
return true;
};
const whenIdle = (fn) =>
"requestIdleCallback" in window
? window.requestIdleCallback(fn, { timeout: 2000 })
: window.setTimeout(fn, 600);
/**
* Run `fn` once `el` comes within about a screen of the viewport. IntersectionObserver does
* the work where it reports normally, but a scroll/resize position check runs alongside it
* so deferred media still appears in embedded or non-compositing views where it does not.
*/
const whenNear = (el, fn) => {
if (!el) return;
let fired = false;
const check = () => {
if (fired) return;
const rect = el.getBoundingClientRect();
const margin = window.innerHeight + 200;
if (rect.top > window.innerHeight + margin || rect.bottom < -margin) return;
fired = true;
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
fn();
};
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check, { passive: true });
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries, observer) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
check();
},
{ rootMargin: "200px 0px" }
);
io.observe(el);
}
check();
};
const heroRoot = document.querySelector("[data-hero-slideshow]");
const heroSlides = heroRoot ? Array.from(heroRoot.querySelectorAll(".hero-slide")) : [];
if (heroRoot && heroSlides.length > 0) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isMobileHero = () =>
window.matchMedia("(max-width: 767px)").matches ||
window.matchMedia("(pointer: coarse)").matches;
let cur = heroSlides.findIndex((el) => el.classList.contains("is-active"));
if (cur < 0) cur = 0;
let heroTimer = 0;
let heroEndedHandler = null;
const clearHeroAdvance = () => {
window.clearTimeout(heroTimer);
heroTimer = 0;
if (heroEndedHandler) {
heroSlides.forEach((el) => {
if (el.tagName === "VIDEO") el.removeEventListener("ended", heroEndedHandler);
});
heroEndedHandler = null;
}
};
const warmNext = () => {
if (heroSlides.length < 2) return;
const next = heroSlides[(cur + 1) % heroSlides.length];
whenIdle(() => attachVideoSource(next));
};
const goHero = (index) => {
cur = ((index % heroSlides.length) + heroSlides.length) % heroSlides.length;
syncHeroMedia();
};
const scheduleAdvance = (vid) => {
clearHeroAdvance();
if (reduceMotion || heroSlides.length < 2) return;
heroEndedHandler = () => {
goHero(cur + 1);
};
vid.addEventListener("ended", heroEndedHandler);
// Cap dwell so a long clip (or a stalled load) cannot freeze the rotation.
// Mobile gets a slightly longer max so radios can finish the next fetch.
const maxMs = isMobileHero() ? 10000 : 8000;
heroTimer = window.setTimeout(() => {
goHero(cur + 1);
}, maxMs);
};
const syncHeroMedia = () => {
heroSlides.forEach((el, i) => {
const active = i === cur;
el.classList.toggle("is-active", active);
if (el.tagName !== "VIDEO") return;
const vid = /** @type {HTMLVideoElement} */ (el);
vid.muted = true;
vid.playsInline = true;
vid.loop = false;
vid.defaultPlaybackRate = 1;
vid.playbackRate = 1;
if (active) {
const justAttached = attachVideoSource(vid);
const playActive = () => {
const p = vid.play();
if (p && typeof p.catch === "function") p.catch(() => {});
scheduleAdvance(vid);
warmNext();
};
if (vid.readyState >= 1) {
try {
vid.currentTime = 0;
} catch (_) {}
playActive();
} else {
vid.addEventListener(
"loadedmetadata",
() => {
try {
vid.currentTime = 0;
} catch (_) {}
playActive();
},
{ once: true }
);
if (!justAttached) vid.load();
}
} else {
vid.pause();
}
});
};
// Always rotate the full set — including on Save-Data / 2G–3G. Lazy attach keeps
// the phone downloading one clip at a time; only reduced-motion freezes on the first.
syncHeroMedia();
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearHeroAdvance();
const active = heroSlides[cur];
if (active && active.tagName === "VIDEO") active.pause();
} else {
syncHeroMedia();
}
});
window.addEventListener(
"pagehide",
() => {
clearHeroAdvance();
},
{ once: true }
);
}
const spotlightRoot = document.querySelector("[data-spotlight-carousel]");
if (spotlightRoot) {
const track = spotlightRoot.querySelector("[data-spotlight-viewport]");
const allSlides = Array.from(spotlightRoot.querySelectorAll("[data-spotlight-slide]"));
const prevBtn = spotlightRoot.querySelector("[data-spotlight-prev]");
const nextBtn = spotlightRoot.querySelector("[data-spotlight-next]");
/**
* Menu-side gallery: phones and tablets only rotate a short clip set and never
* prefetch the next MP4, so the first frame arrives sooner on slower radios.
*/
const compactMq = window.matchMedia("(max-width: 1023px)");
const isCompactSpotlight = () => compactMq.matches || lightMediaMode;
const activeSlides = () => {
if (!isCompactSpotlight()) return allSlides;
return allSlides.slice(0, Math.min(3, allSlides.length));
};
if (track && allSlides.length > 0) {
let current = 0;
let autoplayTimer = 0;
let exitCleanupTimer = 0;
let spotlightStarted = false;
const applyCompactVisibility = () => {
const keep = isCompactSpotlight() ? 3 : allSlides.length;
allSlides.forEach((sl, j) => {
const on = j < keep;
sl.hidden = !on;
if (!on) {
sl.classList.remove("is-active", "is-exiting");
const vid = sl.querySelector("video");
if (vid) {
vid.pause();
/* Drop unused sources so they cannot keep buffering in the background. */
if (vid.dataset.srcAttached === "1" && j >= keep) {
vid.removeAttribute("src");
vid.querySelectorAll("source").forEach((s) => s.remove());
vid.removeAttribute("data-src-attached");
vid.dataset.srcAttached = "0";
try {
vid.load();
} catch (_) {}
}
}
}
});
if (current >= keep) current = 0;
};
const sync = () => {
const slides = activeSlides();
const n = slides.length;
if (current >= n) current = 0;
allSlides.forEach((sl) => {
if (sl.hidden) return;
const idx = slides.indexOf(sl);
const isActive = idx === current;
sl.classList.toggle("is-active", isActive);
if (isActive) sl.classList.remove("is-exiting");
const vid = sl.querySelector("video");
if (!vid) return;
vid.muted = true;
vid.playsInline = true;
if (isActive) {
const justAttached = attachVideoSource(vid);
const play = () => {
const p = vid.play();
if (p && typeof p.catch === "function") p.catch(() => {});
};
if (vid.readyState >= 1) {
play();
} else {
vid.addEventListener("loadedmetadata", play, { once: true });
if (!justAttached) vid.load();
}
} else {
vid.pause();
try {
vid.currentTime = 0;
} catch (_) {}
}
});
};
const goTo = (index) => {
const slides = activeSlides();
const n = slides.length;
if (n <= 0) return;
const prev = current;
current = ((index % n) + n) % n;
if (prev !== current) {
const prevSlide = slides[prev];
if (prevSlide) {
prevSlide.classList.add("is-exiting");
window.clearTimeout(exitCleanupTimer);
exitCleanupTimer = window.setTimeout(() => {
prevSlide.classList.remove("is-exiting");
}, isCompactSpotlight() ? 700 : 1900);
}
}
sync();
};
const goDelta = (delta) => {
goTo(current + delta);
};
const scheduleAutoplay = () => {
window.clearTimeout(autoplayTimer);
const slides = activeSlides();
if (slides.length <= 1) return;
if (lightMediaMode) return;
/* Longer dwell on phones/tablets = fewer sequential downloads. */
const cadence = isCompactSpotlight() ? 9000 : 5200;
const tick = () => {
if (document.hidden) {
autoplayTimer = window.setTimeout(tick, cadence);
return;
}
goDelta(1);
autoplayTimer = window.setTimeout(tick, cadence);
};
autoplayTimer = window.setTimeout(tick, cadence);
};
prevBtn?.addEventListener("click", () => {
goDelta(-1);
if (spotlightStarted) scheduleAutoplay();
});
nextBtn?.addEventListener("click", () => {
goDelta(1);
if (spotlightStarted) scheduleAutoplay();
});
track.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goDelta(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goDelta(1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(activeSlides().length - 1);
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && spotlightStarted) scheduleAutoplay();
});
let resizeTimer = 0;
window.addEventListener("resize", () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
applyCompactVisibility();
sync();
if (spotlightStarted) scheduleAutoplay();
}, 120);
});
window.addEventListener(
"pagehide",
() => {
window.clearTimeout(autoplayTimer);
window.clearTimeout(exitCleanupTimer);
},
{ once: true }
);
const startSpotlight = () => {
if (spotlightStarted) return;
spotlightStarted = true;
applyCompactVisibility();
sync();
scheduleAutoplay();
};
/* Start closer to the fold on phones so we do not kick off an MP4 while still above. */
const nearEl = spotlightRoot;
const whenNearSpotlight = (fn) => {
if (!nearEl) return;
let fired = false;
const marginPx = isCompactSpotlight() ? 80 : 200;
const check = () => {
if (fired) return;
const rect = nearEl.getBoundingClientRect();
if (rect.top > window.innerHeight + marginPx || rect.bottom < -marginPx) return;
fired = true;
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
fn();
};
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check, { passive: true });
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries, observer) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
check();
},
{ rootMargin: `${marginPx}px 0px` }
);
io.observe(nearEl);
}
check();
};
applyCompactVisibility();
whenNearSpotlight(startSpotlight);
}
}
const aboutGlide = document.querySelector("[data-about-glide]");
if (aboutGlide) {
const aboutSlidesEl = aboutGlide.querySelector(".glide__slides");
const aboutSlides = Array.from(aboutGlide.querySelectorAll(".glide__slide"));
if (aboutSlidesEl && aboutSlides.length > 0) {
let current = 0;
let startX = null;
let startY = null;
let wasSwipe = false;
const ABOUT_MS = 3000;
const captionWords = [
"Corner cafe Moment",
"Chef's Detail",
"Counter Light",
"Breakfast Mood",
"Afternoon Plate",
"Tea Study",
];
const makeMeta = (imgSrc, idx) => {
const title = `${captionWords[idx % captionWords.length]}`;
const desc = "Curated cafe frame from the Corner cafe gallery.";
return { title, desc };
};
const lightbox = document.createElement("div");
lightbox.className = "about-lightbox";
lightbox.setAttribute("aria-hidden", "true");
lightbox.innerHTML = `
<button type="button" class="about-lightbox__close" aria-label="Close image">×</button>
<figure class="about-lightbox__figure">
<img class="about-lightbox__img" alt="">
<figcaption class="about-lightbox__meta">
<strong class="about-lightbox__title"></strong>
<span class="about-lightbox__desc"></span>
</figcaption>
</figure>
`;
document.body.appendChild(lightbox);
const lightboxImg = lightbox.querySelector(".about-lightbox__img");
const lightboxTitle = lightbox.querySelector(".about-lightbox__title");
const lightboxDesc = lightbox.querySelector(".about-lightbox__desc");
/* Thumbnails are served at w=800; only the opened image asks for a larger render. */
const fullSizeSrc = (src) => String(src || "").replace(/([?&]w=)\d+/, "$11600");
const openLightbox = (slide) => {
const img = slide.querySelector("img");
if (!img || !lightboxImg || !lightboxTitle || !lightboxDesc) return;
lightboxImg.src = fullSizeSrc(img.currentSrc || img.src);
lightboxImg.alt = img.alt || "";
lightboxTitle.textContent = slide.dataset.aboutTitle || "";
lightboxDesc.textContent = slide.dataset.aboutDesc || "";
lightbox.classList.add("is-open");
lightbox.setAttribute("aria-hidden", "false");
};
const closeLightbox = () => {
lightbox.classList.remove("is-open");
lightbox.setAttribute("aria-hidden", "true");
};
lightbox.addEventListener("click", (e) => {
if (e.target === lightbox || e.target.closest(".about-lightbox__close")) {
closeLightbox();
}
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && lightbox.classList.contains("is-open")) closeLightbox();
});
aboutSlides.forEach((slide, idx) => {
const img = slide.querySelector("img");
if (!img) return;
const meta = makeMeta(img.getAttribute("src") || "", idx);
slide.dataset.aboutTitle = meta.title;
slide.dataset.aboutDesc = meta.desc;
slide.setAttribute("role", "button");
slide.setAttribute("tabindex", "0");
slide.setAttribute("aria-label", `${meta.title}. Open expanded image.`);
const cap = document.createElement("span");
cap.className = "about-glide__caption";
cap.textContent = meta.title;
slide.appendChild(cap);
slide.addEventListener("click", () => {
if (wasSwipe) {
wasSwipe = false;
return;
}
openLightbox(slide);
});
slide.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openLightbox(slide);
}
});
});
const gap = () => {
const st = window.getComputedStyle(aboutSlidesEl);
return parseFloat(st.columnGap || st.gap || "0") || 0;
};
const visible = () => {
if (window.matchMedia("(max-width: 640px)").matches) return 1;
if (window.matchMedia("(max-width: 900px)").matches) return 2;
return 3;
};
const maxIndex = () => Math.max(0, aboutSlides.length - visible());
const step = () => {
const v = visible();
const g = gap();
return v <= 1 ? aboutGlide.clientWidth : (aboutGlide.clientWidth - g * (v - 1)) / v + g;
};
/* Photos are attached for the slides on screen plus a few ahead of the loop. */
let galleryActive = false;
const loadSlideWindow = () => {
if (!galleryActive) return;
const last = current + visible() + 3;
for (let i = current; i <= last; i += 1) {
const img = aboutSlides[i % aboutSlides.length].querySelector("img[data-src]");
if (!img) continue;
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
};
const syncAbout = (animate = true) => {
const clamped = Math.max(0, Math.min(maxIndex(), current));
if (clamped !== current) current = clamped;
loadSlideWindow();
aboutSlidesEl.style.transition = animate
? "transform 0.55s cubic-bezier(0.22, 1, 0.36, 1)"
: "none";
aboutSlidesEl.style.transform = `translate3d(-${current * step()}px, 0, 0)`;
};
const goAbout = (index, animate = true) => {
const max = maxIndex();
if (max <= 0) {
current = 0;
} else if (index > max) {
current = 0;
} else if (index < 0) {
current = max;
} else {
current = index;
}
syncAbout(animate);
};
let aboutTimer = 0;
const startAboutLoop = () => {
window.clearInterval(aboutTimer);
if (aboutSlides.length <= visible()) return;
aboutTimer = window.setInterval(() => {
if (document.hidden) return;
goAbout(current + 1, true);
}, ABOUT_MS);
};
const onTouchStart = (e) => {
const touch = e.touches && e.touches[0];
if (!touch) return;
startX = touch.clientX;
startY = touch.clientY;
wasSwipe = false;
};
const onTouchEnd = (e) => {
if (startX === null || startY === null) return;
const touch = e.changedTouches && e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
startX = null;
startY = null;
if (Math.abs(dx) < 44 || Math.abs(dx) <= Math.abs(dy)) return;
wasSwipe = true;
goAbout(current + (dx < 0 ? 1 : -1), true);
};
aboutGlide.addEventListener("touchstart", onTouchStart, { passive: true });
aboutGlide.addEventListener("touchend", onTouchEnd, { passive: true });
aboutGlide.addEventListener("pointerdown", (e) => {
startX = e.clientX;
startY = e.clientY;
wasSwipe = false;
});
aboutGlide.addEventListener("pointerup", (e) => {
if (startX === null || startY === null) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
startX = null;
startY = null;
if (Math.abs(dx) < 44 || Math.abs(dx) <= Math.abs(dy)) return;
wasSwipe = true;
goAbout(current + (dx < 0 ? 1 : -1), true);
});
aboutGlide.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goAbout(current - 1, true);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goAbout(current + 1, true);
}
});
syncAbout(false);
let aboutResizeTimer = 0;
window.addEventListener("resize", () => {
window.clearTimeout(aboutResizeTimer);
aboutResizeTimer = window.setTimeout(() => syncAbout(false), 120);
});
/* The gallery only starts cycling once it is on screen, so it never pulls images early. */
const startGallery = () => {
galleryActive = true;
loadSlideWindow();
startAboutLoop();
};
whenNear(aboutGlide, startGallery);
window.addEventListener(
"pagehide",
() => {
window.clearInterval(aboutTimer);
},
{ once: true }
);
}
}
const pressSection = document.querySelector("section#press.press-section");
const pressCarousel = document.querySelector(".press-grid--single");
if (pressCarousel) {
const cards = Array.from(pressCarousel.querySelectorAll(".press-card"));
if (cards.length > 0) {
let current = 0;
const sync = () => {
cards.forEach((card, idx) => {
card.classList.toggle("is-active", idx === current);
card.setAttribute("aria-hidden", idx === current ? "false" : "true");
});
};
sync();
if (cards.length > 1) {
const PRESS_MS = 4800;
const timer = window.setInterval(() => {
if (document.hidden) return;
current = (current + 1) % cards.length;
sync();
}, PRESS_MS);
window.addEventListener(
"pagehide",
() => {
window.clearInterval(timer);
},
{ once: true }
);
}
}
}
if (pressSection && "IntersectionObserver" in window) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
pressSection.classList.add("press-section--visible");
} else {
const pressIo = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("press-section--visible");
observer.unobserve(entry.target);
});
},
{ threshold: 0.12, rootMargin: "0px 0px -5% 0px" }
);
pressIo.observe(pressSection);
}
} else if (pressSection) {
pressSection.classList.add("press-section--visible");
}
/* Floating contact form → POST /api/contact (SMTP mail server) */
const contactModal = document.querySelector("[data-contact-modal]");
const contactForm = document.querySelector("[data-contact-form]");
const contactStatus = document.querySelector("[data-contact-status]");
const contactSubmit = document.querySelector("[data-contact-submit]");
let contactLastFocus = null;
const setContactStatus = (text, kind) => {
if (!contactStatus) return;
contactStatus.textContent = text || "";
contactStatus.classList.remove("is-error", "is-ok");
if (kind) contactStatus.classList.add(kind);
};
const setContactFieldsActive = (active) => {
if (!contactForm) return;
contactForm.querySelectorAll("input, textarea, button").forEach((el) => {
if (el.name === "website") {
el.tabIndex = -1;
return;
}
el.disabled = !active;
});
};
const openContact = (prefill) => {
if (!contactModal) return;
contactLastFocus = document.activeElement;
contactModal.hidden = false;
contactModal.removeAttribute("inert");
document.body.style.overflow = "hidden";
setContactStatus("", null);
setContactFieldsActive(true);
if (contactForm && prefill) {
if (prefill.subject) contactForm.elements.subject.value = prefill.subject;
if (prefill.message) contactForm.elements.message.value = prefill.message;
}
const first = contactForm && contactForm.querySelector('input[name="email"]');
window.setTimeout(() => {
if (first) first.focus();
}, 30);
};
const closeContact = () => {
if (!contactModal) return;
contactModal.hidden = true;
contactModal.setAttribute("inert", "");
document.body.style.overflow = "";
setContactFieldsActive(false);
if (contactLastFocus && typeof contactLastFocus.focus === "function") {
contactLastFocus.focus();
}
};
/* Start closed: form must not show or accept input until Contact us */
if (contactModal) {
contactModal.hidden = true;
contactModal.setAttribute("inert", "");
setContactFieldsActive(false);
}
document.addEventListener("click", (e) => {
const openEl = e.target.closest("[data-contact-open]");
if (openEl) {
e.preventDefault();
openContact();
return;
}
if (e.target.closest("[data-contact-close]")) {
e.preventDefault();
closeContact();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && contactModal && !contactModal.hidden) {
closeContact();
}
});
if (contactForm) {
contactForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(contactForm);
const body = {
email: String(fd.get("email") || "").trim(),
subject: String(fd.get("subject") || "").trim(),
message: String(fd.get("message") || "").trim(),
website: String(fd.get("website") || ""),
};
if (!body.email || !body.subject || !body.message) {
setContactStatus("Please fill in email, subject and message.", "is-error");
return;
}
if (contactSubmit) contactSubmit.disabled = true;
setContactStatus("Sending…", null);
try {
// Prefer on-server SMTP API when the Space/host runs app.py (Docker / local).
let usedApi = false;
try {
const health = await fetch("/api/health", { headers: { Accept: "application/json" } });
if (health.ok) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
usedApi = true;
let detail = "";
try {
const data = await res.json();
detail = data.detail || data.message || "";
if (Array.isArray(detail)) {
detail = detail.map((d) => d.msg || JSON.stringify(d)).join(" ");
}
} catch (_) {}
if (!res.ok) {
setContactStatus(detail || `Could not send (${res.status}).`, "is-error");
return;
}
setContactStatus("Sent — thank you. We’ll reply by email.", "is-ok");
contactForm.reset();
window.setTimeout(() => closeContact(), 1400);
return;
}
} catch (_) {
/* fall through to FormSubmit when API is not hosted (static HF Space) */
}
// Static hosting fallback: FormSubmit AJAX → pd3rvr@icloud.com (no local mail app).
const fsRes = await fetch("https://formsubmit.co/ajax/pd3rvr@icloud.com", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
email: body.email,
subject: body.subject,
message: body.message,
_subject: `[Corner cafe] ${body.subject}`,
_template: "table",
_captcha: "false",
}),
});
const fsData = await fsRes.json().catch(() => ({}));
if (!fsRes.ok || fsData.success === "false" || fsData.success === false) {
setContactStatus(
(fsData && (fsData.message || fsData.error)) || "Could not send. Please try again.",
"is-error"
);
return;
}
setContactStatus("Sent — thank you. We’ll reply by email.", "is-ok");
contactForm.reset();
window.setTimeout(() => closeContact(), 1400);
} catch (_) {
setContactStatus("Network error — please try again.", "is-error");
} finally {
if (contactSubmit) contactSubmit.disabled = false;
}
});
}
})();