const watchedDateInput = document.querySelector("#date_watched"); const shelfInput = document.querySelector("#shelf"); const syncWatchedDateForShelf = () => { if (!watchedDateInput || !shelfInput) return; const isQueue = shelfInput.value === "queue"; watchedDateInput.disabled = isQueue; if (isQueue) { watchedDateInput.value = ""; return; } if (!watchedDateInput.value && watchedDateInput.hasAttribute("data-default-today")) { watchedDateInput.value = new Date().toLocaleDateString("en-CA"); } }; if (watchedDateInput && shelfInput) { syncWatchedDateForShelf(); shelfInput.addEventListener("change", syncWatchedDateForShelf); } else { const dateDefault = document.querySelector("[data-default-today]"); if (dateDefault) { dateDefault.value = new Date().toLocaleDateString("en-CA"); } } // Hamburger menu toggle const menuToggle = document.querySelector("#menu-toggle"); const navActions = document.querySelector("#nav-actions"); if (menuToggle && navActions) { menuToggle.addEventListener("click", () => { const isOpen = navActions.classList.toggle("is-open"); menuToggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); }); // Close menu when a link is clicked navActions.querySelectorAll("a").forEach((link) => { link.addEventListener("click", () => { navActions.classList.remove("is-open"); menuToggle.setAttribute("aria-expanded", "false"); }); }); // Close menu when clicking outside document.addEventListener("click", (event) => { if (!navActions.contains(event.target) && !menuToggle.contains(event.target)) { navActions.classList.remove("is-open"); menuToggle.setAttribute("aria-expanded", "false"); } }); } const tmdbQuery = document.querySelector("#tmdb-query"); const tmdbButton = document.querySelector("#tmdb-search"); const tmdbResults = document.querySelector("#tmdb-results"); const syncStarControl = (control, stars) => { control.dataset.currentStars = String(stars); control.dataset.previewStars = ""; control.querySelectorAll(".star-button").forEach((button) => { const value = Number(button.dataset.stars || 0); const active = stars >= value; button.classList.toggle("is-active", active); button.classList.remove("is-preview"); button.setAttribute("aria-pressed", active ? "true" : "false"); }); }; const previewStarControl = (control, stars) => { control.dataset.previewStars = String(stars); control.querySelectorAll(".star-button").forEach((button) => { const value = Number(button.dataset.stars || 0); const previewed = stars >= value; button.classList.toggle("is-preview", previewed); }); }; const clearStarPreview = (control) => { control.dataset.previewStars = ""; const stars = Number(control.dataset.currentStars || 0); control.querySelectorAll(".star-button").forEach((button) => { const value = Number(button.dataset.stars || 0); button.classList.toggle("is-preview", false); button.classList.toggle("is-active", stars >= value); }); }; const setValue = (selector, value) => { const element = document.querySelector(selector); if (element && value !== null && value !== undefined) { element.value = value; } }; const setPoster = (url) => { setValue("#poster_url", url || ""); const preview = document.querySelector("#poster-preview"); if (!preview) return; if (url) { preview.src = url; preview.classList.add("is-visible"); } else { preview.removeAttribute("src"); preview.classList.remove("is-visible"); } }; const clearResults = () => { if (tmdbResults) { tmdbResults.replaceChildren(); } }; const renderMessage = (message) => { clearResults(); const node = document.createElement("p"); node.className = "tmdb-message"; node.textContent = message; tmdbResults.appendChild(node); }; const applyResult = async (film) => { setValue("#title", film.title || ""); setValue("#original_title", film.original_title || ""); setValue("#director", film.director || ""); setValue("#year", film.year || ""); setValue("#country", film.country || ""); setValue("#language", film.language || ""); setValue("#runtime", film.runtime || ""); setValue("#tmdb_id", film.tmdb_id || ""); setPoster(film.poster_url); const duplicateNotice = document.getElementById("duplicate-notice"); if (duplicateNotice) duplicateNotice.hidden = true; const isAddForm = window.location.pathname.endsWith("/films/new"); if (film.tmdb_id) { try { const [detailResponse, rewatchResponse, findResponse] = await Promise.all([ fetch(`/tmdb/detail/${film.tmdb_id}`), isAddForm ? fetch(`/films/check-rewatch?tmdb_id=${film.tmdb_id}`) : Promise.resolve(null), isAddForm ? fetch(`/films/find?tmdb_id=${film.tmdb_id}`) : Promise.resolve(null), ]); if (detailResponse.ok) { const detail = await detailResponse.json(); setValue("#genre", detail.genre || ""); } if (rewatchResponse && rewatchResponse.ok) { const rw = await rewatchResponse.json(); if (rw.count > 0) { const rewatchCheckbox = document.getElementById("rewatch"); if (rewatchCheckbox) { rewatchCheckbox.checked = true; setValue("#rewatch_count", String(rw.count)); } } } if (findResponse && findResponse.ok && duplicateNotice) { const found = await findResponse.json(); if (found.matches && found.matches.length > 0) { const shelfLabel = { diary: "Diary", queue: "Queue", abandoned: "Abandoned" }; const parts = found.matches.map((m) => { const label = shelfLabel[m.shelf] || m.shelf; const date = m.date_watched ? ` · ${new Date(m.date_watched + "T00:00:00").toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""; return `${label}${date}`; }); duplicateNotice.innerHTML = `Already logged — ${parts.join(", ")}`; duplicateNotice.hidden = false; } } } catch (error) { console.error("Failed to fetch details", error); } } clearResults(); }; const renderResults = (films) => { clearResults(); if (!films.length) { renderMessage("No matches found."); return; } films.forEach((film) => { const button = document.createElement("button"); button.type = "button"; button.className = "tmdb-result"; button.addEventListener("click", async () => { button.disabled = true; try { await applyResult(film); } finally { button.disabled = false; } }); if (film.poster_url) { const image = document.createElement("img"); image.src = film.poster_url; image.alt = ""; button.appendChild(image); } const text = document.createElement("span"); const title = document.createElement("strong"); title.textContent = film.year ? `${film.title} (${film.year})` : film.title; text.appendChild(title); if (film.director) { const director = document.createElement("small"); director.textContent = film.director; text.appendChild(director); } button.appendChild(text); tmdbResults.appendChild(button); }); }; const searchTmdb = async () => { if (!tmdbQuery || !tmdbResults) return; const query = tmdbQuery.value.trim(); if (query.length < 2) { renderMessage("Enter at least two characters."); return; } renderMessage("Searching..."); try { const response = await fetch(`/tmdb/search?q=${encodeURIComponent(query)}`); const data = await response.json(); if (!response.ok) { renderMessage(data.error || "Search failed."); return; } renderResults(data.results || []); } catch (error) { renderMessage("Search failed."); } }; if (tmdbButton && tmdbQuery) { tmdbButton.addEventListener("click", searchTmdb); tmdbQuery.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); searchTmdb(); } }); } document.querySelectorAll("form[data-confirm]").forEach((form) => { form.addEventListener("submit", (event) => { const message = form.dataset.confirm; if (message && !window.confirm(message)) { event.preventDefault(); } }); }); document.querySelectorAll(".star-control:not([data-form-stars])").forEach((control) => { syncStarControl(control, Number(control.dataset.currentStars || 0)); control.addEventListener("pointerleave", () => clearStarPreview(control)); control.addEventListener("focusout", (event) => { if (!control.contains(event.relatedTarget)) { clearStarPreview(control); } }); control.querySelectorAll(".star-button").forEach((button) => { button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("click", async () => { const currentStars = Number(control.dataset.currentStars || 0); const selectedStars = Number(button.dataset.stars || 0); const nextStars = currentStars === selectedStars ? 0 : selectedStars; button.disabled = true; try { const response = await fetch(`/films/${control.dataset.filmId}/stars`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ stars: nextStars }), }); const data = await response.json(); if (!response.ok) { return; } syncStarControl(control, Number(data.stars || 0)); } catch (error) { console.error("Failed to update stars", error); } finally { button.disabled = false; } }); }); }); // Form star picker — updates hidden input instead of fetching document.querySelectorAll(".star-control[data-form-stars]").forEach((control) => { syncStarControl(control, Number(control.dataset.currentStars || 0)); control.addEventListener("pointerleave", () => clearStarPreview(control)); control.addEventListener("focusout", (event) => { if (!control.contains(event.relatedTarget)) clearStarPreview(control); }); control.querySelectorAll(".star-button").forEach((button) => { button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("click", () => { const current = Number(control.dataset.currentStars || 0); const selected = Number(button.dataset.stars || 0); const next = current === selected ? 0 : selected; syncStarControl(control, next); const hidden = document.getElementById("stars"); if (hidden) hidden.value = String(next); }); }); }); // Infinite scroll + search/filter/sort (function () { const feedContainer = document.querySelector("#film-feed"); if (!feedContainer) return; const shelf = feedContainer.dataset.shelf; if (!shelf) return; let currentQ = ""; let currentSort = ""; function attachStarListeners(root) { root.querySelectorAll(".star-control:not([data-form-stars])").forEach((control) => { if (control.dataset.listenerAttached) return; control.dataset.listenerAttached = "true"; syncStarControl(control, Number(control.dataset.currentStars || 0)); control.addEventListener("pointerleave", () => clearStarPreview(control)); control.addEventListener("focusout", (event) => { if (!control.contains(event.relatedTarget)) clearStarPreview(control); }); control.querySelectorAll(".star-button").forEach((button) => { button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0))); button.addEventListener("click", async () => { const currentStars = Number(control.dataset.currentStars || 0); const selectedStars = Number(button.dataset.stars || 0); const nextStars = currentStars === selectedStars ? 0 : selectedStars; button.disabled = true; try { const resp = await fetch(`/films/${control.dataset.filmId}/stars`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stars: nextStars }), }); const data = await resp.json(); if (!resp.ok) return; syncStarControl(control, Number(data.stars || 0)); } catch (err) { console.error("Failed to update stars", err); } finally { button.disabled = false; } }); }); }); } function mergeMonthGroups() { const groups = feedContainer.querySelectorAll(".month-group"); for (let i = groups.length - 1; i > 0; i--) { const cur = groups[i]; const prev = groups[i - 1]; if (cur.dataset.month === prev.dataset.month) { const prevStack = prev.querySelector(".month-stack"); const curStack = cur.querySelector(".month-stack"); if (!prevStack || !curStack) continue; curStack.querySelectorAll(".film-card").forEach((card) => prevStack.appendChild(card)); cur.remove(); } } } let observer = null; let loading = false; function startInfiniteScroll(sentinel) { if (observer) observer.disconnect(); loading = false; const loadMore = async () => { if (loading) return; loading = true; const offset = Number(sentinel.dataset.offset); const params = new URLSearchParams({ shelf, offset, limit: 20 }); if (currentQ) params.set("q", currentQ); if (currentSort) params.set("sort", currentSort); try { const response = await fetch(`/films/partial?${params}`); if (!response.ok) return; const hasMore = response.headers.get("X-Has-More") === "true"; const html = await response.text(); if (html.trim()) { feedContainer.insertAdjacentHTML("beforeend", html); if (shelf === "diary" && !currentQ) mergeMonthGroups(); attachStarListeners(feedContainer); } if (!hasMore) { observer.disconnect(); sentinel.remove(); } else { sentinel.dataset.offset = String(offset + 20); } } catch (err) { console.error("Failed to load more films", err); } finally { loading = false; } }; observer = new IntersectionObserver( (entries) => entries.forEach((e) => { if (e.isIntersecting) loadMore(); }), { rootMargin: "100px" } ); observer.observe(sentinel); } const initialSentinel = document.querySelector("#feed-sentinel"); if (initialSentinel) startInfiniteScroll(initialSentinel); async function resetFeed() { if (observer) { observer.disconnect(); observer = null; } const params = new URLSearchParams({ shelf, offset: 0, limit: 20 }); if (currentQ) params.set("q", currentQ); if (currentSort) params.set("sort", currentSort); let html = ""; let hasMore = false; try { const response = await fetch(`/films/partial?${params}`); if (response.ok) { hasMore = response.headers.get("X-Has-More") === "true"; html = await response.text(); } } catch (err) { console.error("Failed to reset feed", err); } feedContainer.replaceChildren(); if (html.trim()) { feedContainer.insertAdjacentHTML("beforeend", html); attachStarListeners(feedContainer); } const oldSentinel = document.querySelector("#feed-sentinel"); if (oldSentinel) oldSentinel.remove(); if (hasMore) { const newSentinel = document.createElement("div"); newSentinel.id = "feed-sentinel"; newSentinel.dataset.shelf = shelf; newSentinel.dataset.offset = "20"; newSentinel.style.cssText = "height: 1px; margin: 20px 0;"; feedContainer.insertAdjacentElement("afterend", newSentinel); startInfiniteScroll(newSentinel); } } const searchInput = document.querySelector("#film-search"); const sortSelect = document.querySelector("#film-sort"); if (searchInput) { let debounceTimer = null; searchInput.addEventListener("input", () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { currentQ = searchInput.value.trim(); resetFeed(); }, 300); }); } if (sortSelect) { sortSelect.addEventListener("change", () => { currentSort = sortSelect.value; resetFeed(); }); } })();