diff --git a/anime.js b/anime.js index da8588b..f1d03b1 100644 --- a/anime.js +++ b/anime.js @@ -3,7 +3,7 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes const ITEMS_PER_PAGE = 9; const DEBOUNCE_DELAY = 400; -const FETCH_TIMEOUT = 12000; +const FETCH_TIMEOUT = 30000; const RECENTLY_VIEWED_LIMIT = 10; let currentPage = 1; @@ -39,8 +39,10 @@ const viewSeasonal = document.getElementById("view-seasonal"); const viewWeekly = document.getElementById("view-weekly"); const viewMonthly = document.getElementById("view-monthly"); const viewYearly = document.getElementById("view-yearly"); +const viewTop = document.getElementById("view-top"); const pageHeading = document.getElementById("page-heading"); const pageSubheading = document.getElementById("page-subheading"); +const themeToggle = document.getElementById("theme-toggle"); // --- Accessibility upgrades (safe if missing) --- if (resultsInfo) resultsInfo.setAttribute("aria-live", "polite"); @@ -95,6 +97,24 @@ function safeParse(value, fallback) { } } +// Theme toggle +function setTheme(mode) { + const html = document.documentElement; + if (mode === "light") { + html.classList.add("light"); + if (themeToggle) themeToggle.textContent = "🌙"; + } else { + html.classList.remove("light"); + if (themeToggle) themeToggle.textContent = "☀️"; + } + localStorage.setItem("theme", mode); +} + +function toggleTheme() { + const isLight = document.documentElement.classList.contains("light"); + setTheme(isLight ? "dark" : "light"); +} + // Persist filters function saveFilters() { const filters = { @@ -131,11 +151,12 @@ function updateFilterVisibility() { const isWeekly = currentViewMode === "weekly"; const isMonthly = currentViewMode === "monthly"; const isYearly = currentViewMode === "yearly"; + const isTop = currentViewMode === "top"; // Season select: visible in seasonal + yearly (for yearly mode it's the season picker) seasonSelect?.closest("div")?.classList.toggle("hidden", !isSeasonal && !isYearly); // Year select: visible in seasonal + monthly + yearly - yearSelect?.closest("div")?.classList.toggle("hidden", isWeekly); + yearSelect?.closest("div")?.classList.toggle("hidden", isWeekly || isTop); // Day filter: visible only in weekly mode dayFilter?.classList.toggle("hidden", !isWeekly); // Month filter: visible only in monthly mode @@ -144,14 +165,17 @@ function updateFilterVisibility() { function setViewMode(mode) { currentViewMode = mode; - // update tab styles - [viewSeasonal, viewWeekly, viewMonthly, viewYearly].forEach((btn) => { - btn?.classList.remove("bg-blue-500", "text-white"); - btn?.classList.add("bg-zinc-700", "text-zinc-300", "hover:bg-zinc-600"); + const inactiveDark = ["bg-zinc-700", "text-zinc-300", "hover:bg-zinc-600"]; + const inactiveLight = ["light:bg-zinc-200", "light:text-zinc-700", "light:hover:bg-zinc-300"]; + const activeDark = ["bg-blue-500", "text-white"]; + const activeLight = ["light:bg-blue-600", "light:text-white"]; + [viewSeasonal, viewWeekly, viewMonthly, viewTop, viewYearly].forEach((btn) => { + btn?.classList.remove(...activeDark, ...activeLight); + btn?.classList.add(...inactiveDark, ...inactiveLight); }); - const activeBtn = { seasonal: viewSeasonal, weekly: viewWeekly, monthly: viewMonthly, yearly: viewYearly }[mode]; - activeBtn?.classList.remove("bg-zinc-700", "text-zinc-300", "hover:bg-zinc-600"); - activeBtn?.classList.add("bg-blue-500", "text-white"); + const activeBtn = { seasonal: viewSeasonal, weekly: viewWeekly, monthly: viewMonthly, yearly: viewYearly, top: viewTop }[mode]; + activeBtn?.classList.remove(...inactiveDark, ...inactiveLight); + activeBtn?.classList.add(...activeDark, ...activeLight); // heading const labels = { @@ -159,6 +183,7 @@ function setViewMode(mode) { weekly: ["📅 Weekly Schedule", "Browse your favorite animes by day"], monthly: ["📅 Monthly Anime", "Browse your favorite animes by month"], yearly: ["📅 Yearly Anime", "Browse your favorite animes by year"], + top: ["🏆 Top Anime", "Browse the highest rated anime"], }; const [h, s] = labels[mode] || labels.seasonal; if (pageHeading) pageHeading.textContent = h; @@ -306,7 +331,7 @@ function openModal(anime) { }); } else { const span = document.createElement("span"); - span.className = "text-zinc-400"; + span.className = "text-zinc-400 light:text-zinc-500"; span.textContent = "No genres available"; genresContainer.appendChild(span); } @@ -336,6 +361,9 @@ function openModal(anime) { // Favorites button setUpFavButton(anime); + // Recommendations + loadRecommendations(anime.mal_id); + // Show modal + focus trap previouslyFocusedEl = document.activeElement; modal.classList.remove("hidden"); @@ -361,6 +389,70 @@ function closeModalFn() { }, 300); } +const REC_CACHE_KEY_PREFIX = "jikan_rec_cache_"; + +async function loadRecommendations(malId) { + const section = document.getElementById("modal-recommendations-section"); + const container = document.getElementById("modal-recommendations"); + if (!section || !container) return; + + container.innerHTML = ""; + const cacheKey = REC_CACHE_KEY_PREFIX + malId; + const cachedRaw = sessionStorage.getItem(cacheKey); + if (cachedRaw) { + const cached = safeParse(cachedRaw, {}); + if (Date.now() - cached.time < CACHE_TTL && Array.isArray(cached.data)) { + renderRecommendations(cached.data, section, container); + return; + } + } + + try { + const res = await fetch(`https://api.jikan.moe/v4/anime/${malId}/recommendations`); + if (!res.ok) { section.classList.add("hidden"); return; } + const json = await res.json(); + const recs = (json.data || []).slice(0, 10); + sessionStorage.setItem(cacheKey, JSON.stringify({ time: Date.now(), data: recs })); + renderRecommendations(recs, section, container); + } catch { + section.classList.add("hidden"); + } +} + +function renderRecommendations(recs, section, container) { + section.classList.remove("hidden"); + container.innerHTML = ""; + recs.forEach((entry) => { + const anime = entry.entry; + if (!anime) return; + const card = document.createElement("div"); + card.className = "flex-shrink-0 w-24 cursor-pointer hover:opacity-80 transition-opacity"; + const img = document.createElement("img"); + img.src = anime.images?.webp?.image_url || anime.images?.jpg?.image_url || ""; + img.alt = anime.title || ""; + img.loading = "lazy"; + img.className = "w-full h-32 object-cover rounded"; + const title = document.createElement("p"); + title.className = "text-xs text-zinc-300 mt-1 truncate light:text-zinc-700"; + title.textContent = anime.title || ""; + card.appendChild(img); + card.appendChild(title); + card.addEventListener("click", () => openModalById(anime.mal_id)); + container.appendChild(card); + }); +} + +function openModalById(malId) { + showLoading(); + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + fetch(`https://api.jikan.moe/v4/anime/${malId}`, { signal: controller.signal }) + .then(r => { clearTimeout(t); if (!r.ok) throw new Error(); return r.json(); }) + .then(data => { if (data?.data) openModal(data.data); }) + .catch(() => { hideLoading(); }) + .finally(() => clearTimeout(t)); +} + // Favorites function toggleFavorite(anime) { const exists = favorites.some((f) => f.mal_id === anime.mal_id); @@ -408,7 +500,7 @@ function renderRecentlyViewed() { recentlyViewed.forEach((anime) => { const card = document.createElement("div"); card.className = - "flex-shrink-0 bg-zinc-800 rounded-lg p-2 cursor-pointer hover:bg-zinc-700 transition-colors w-32"; + "flex-shrink-0 bg-zinc-800 rounded-lg p-2 cursor-pointer hover:bg-zinc-700 transition-colors w-32 light:bg-zinc-100 light:hover:bg-zinc-200"; const img = document.createElement("img"); img.src = anime.image || ""; @@ -418,11 +510,11 @@ function renderRecentlyViewed() { setImageFallback(img); const title = document.createElement("p"); - title.className = "text-xs text-white font-medium truncate"; + title.className = "text-xs text-white font-medium truncate light:text-zinc-800"; title.textContent = anime.title; const score = document.createElement("p"); - score.className = "text-xs text-zinc-400"; + score.className = "text-xs text-zinc-400 light:text-zinc-500"; score.textContent = anime.score ? `⭐ ${anime.score}` : "No score"; card.appendChild(img); @@ -452,7 +544,7 @@ function renderFavorites() { favorites.forEach((anime) => { const card = document.createElement("div"); - card.className = "bg-zinc-800 rounded-lg p-2 cursor-pointer hover:bg-zinc-700 transition-colors"; + card.className = "bg-zinc-800 rounded-lg p-2 cursor-pointer hover:bg-zinc-700 transition-colors light:bg-zinc-100 light:hover:bg-zinc-200"; const img = document.createElement("img"); img.src = anime.image || ""; @@ -462,11 +554,11 @@ function renderFavorites() { setImageFallback(img); const title = document.createElement("p"); - title.className = "text-xs text-white font-medium truncate"; + title.className = "text-xs text-white font-medium truncate light:text-zinc-800"; title.textContent = anime.title; const score = document.createElement("p"); - score.className = "text-xs text-zinc-400"; + score.className = "text-xs text-zinc-400 light:text-zinc-500"; score.textContent = anime.score ? `⭐ ${anime.score}` : "No score"; card.appendChild(img); @@ -484,6 +576,7 @@ function renderFavorites() { // Fetch with timeout + session cache function getApiUrl() { if (currentViewMode === "weekly") return "https://api.jikan.moe/v4/schedules"; + if (currentViewMode === "top") return "https://api.jikan.moe/v4/top/anime"; if (currentViewMode === "yearly") { const season = seasonSelect?.value || ""; const year = yearSelect?.value || ""; @@ -495,6 +588,7 @@ function getApiUrl() { function getCacheKey() { if (currentViewMode === "weekly") return "jikan_schedule_cache_v1"; + if (currentViewMode === "top") return "jikan_top_cache_v1"; if (currentViewMode === "yearly") { const season = seasonSelect?.value || ""; const year = yearSelect?.value || ""; @@ -511,7 +605,7 @@ async function fetchAnime() { if (animeCards) { animeCards.innerHTML = ""; const msg = document.createElement("p"); - msg.className = "text-zinc-400 text-center py-10"; + msg.className = "text-zinc-400 text-center py-10 light:text-zinc-500"; msg.textContent = "Select a year and season to browse."; animeCards.appendChild(msg); } @@ -556,7 +650,7 @@ async function fetchAnime() { if (animeCards) { animeCards.innerHTML = ""; const container = document.createElement("div"); - container.className = "text-center py-10 text-zinc-300"; + container.className = "text-center py-10 text-zinc-300 light:text-zinc-600"; const msg = document.createElement("p"); msg.className = "text-red-500 mb-4"; @@ -663,7 +757,7 @@ function renderAnime() { pageData.forEach((anime) => { const card = document.createElement("div"); card.className = - "bg-zinc-800 p-4 rounded-xl shadow-lg hover:shadow-blue-400 hover:scale-105 transition-all duration-300 cursor-pointer relative"; + "bg-zinc-800 p-4 rounded-xl shadow-lg hover:shadow-blue-400 hover:scale-105 transition-all duration-300 cursor-pointer relative light:bg-white light:shadow-gray-200 light:hover:shadow-blue-300"; // Image wrapper const imgWrapper = document.createElement("div"); @@ -679,22 +773,22 @@ function renderAnime() { setImageFallback(img); const scoreBadge = document.createElement("div"); - scoreBadge.className = "absolute top-2 right-2 bg-black bg-opacity-70 text-white px-2 py-1 rounded text-xs"; + scoreBadge.className = "absolute top-2 right-2 bg-black bg-opacity-70 text-white px-2 py-1 rounded text-xs light:bg-white light:bg-opacity-80 light:text-zinc-800 light:shadow"; scoreBadge.textContent = anime.score ? `⭐ ${anime.score}` : "No score"; imgWrapper.appendChild(img); imgWrapper.appendChild(scoreBadge); const titleEl = document.createElement("h3"); - titleEl.className = "text-lg font-bold text-blue-400 mb-1 line-clamp-2"; + titleEl.className = "text-lg font-bold text-blue-400 mb-1 line-clamp-2 light:text-blue-600"; titleEl.textContent = anime.title || "Untitled"; const eps = document.createElement("p"); - eps.className = "text-sm text-zinc-400"; + eps.className = "text-sm text-zinc-400 light:text-zinc-500"; eps.textContent = `Episodes: ${anime.episodes ?? "N/A"}`; const status = document.createElement("p"); - status.className = "text-sm text-zinc-400"; + status.className = "text-sm text-zinc-400 light:text-zinc-500"; status.textContent = `Status: ${anime.status || "N/A"}`; card.appendChild(imgWrapper); @@ -725,7 +819,7 @@ function renderPagination() { const prevBtn = document.createElement("button"); prevBtn.textContent = "← Previous"; prevBtn.className = - "px-4 py-2 bg-zinc-700 text-zinc-300 hover:bg-blue-400 rounded-md transition-colors"; + "px-4 py-2 bg-zinc-700 text-zinc-300 hover:bg-blue-400 rounded-md transition-colors light:bg-zinc-200 light:text-zinc-700 light:hover:bg-blue-400 light:hover:text-white"; prevBtn.addEventListener("click", () => goToPage(currentPage - 1)); pagination.appendChild(prevBtn); } @@ -737,7 +831,9 @@ function renderPagination() { const btn = document.createElement("button"); btn.textContent = i; btn.className = `px-3 py-2 rounded-md transition-colors ${ - i === currentPage ? "bg-blue-500 text-white" : "bg-zinc-700 text-zinc-300 hover:bg-blue-400" + i === currentPage + ? "bg-blue-500 text-white light:bg-blue-600" + : "bg-zinc-700 text-zinc-300 hover:bg-blue-400 light:bg-zinc-200 light:text-zinc-700 light:hover:bg-blue-400 light:hover:text-white" }`; btn.addEventListener("click", () => goToPage(i)); pagination.appendChild(btn); @@ -747,7 +843,7 @@ function renderPagination() { const nextBtn = document.createElement("button"); nextBtn.textContent = "Next →"; nextBtn.className = - "px-4 py-2 bg-zinc-700 text-zinc-300 hover:bg-blue-400 rounded-md transition-colors"; + "px-4 py-2 bg-zinc-700 text-zinc-300 hover:bg-blue-400 rounded-md transition-colors light:bg-zinc-200 light:text-zinc-700 light:hover:bg-blue-400 light:hover:text-white"; nextBtn.addEventListener("click", () => goToPage(currentPage + 1)); pagination.appendChild(nextBtn); } @@ -802,8 +898,12 @@ document.addEventListener("keydown", (e) => { viewSeasonal?.addEventListener("click", () => setViewMode("seasonal")); viewWeekly?.addEventListener("click", () => setViewMode("weekly")); viewMonthly?.addEventListener("click", () => setViewMode("monthly")); +viewTop?.addEventListener("click", () => setViewMode("top")); viewYearly?.addEventListener("click", () => setViewMode("yearly")); +// Theme toggle +themeToggle?.addEventListener("click", toggleTheme); + // Day / Month filters daySelect?.addEventListener("change", () => { currentPage = 1; applyFilters(); }); monthSelect?.addEventListener("change", () => { currentPage = 1; applyFilters(); }); @@ -833,6 +933,8 @@ function populateYearFilter() { } // Initialize +const savedTheme = localStorage.getItem("theme"); +if (savedTheme) setTheme(savedTheme); populateYearFilter(); loadFilters(); renderFavorites(); diff --git a/src/style.css b/src/style.css index f1d8c73..5ea2bf9 100644 --- a/src/style.css +++ b/src/style.css @@ -1 +1,2 @@ @import "tailwindcss"; +@custom-variant light (&:where(.light, .light *)); diff --git a/updates.html b/updates.html index 0a826db..be56c1c 100644 --- a/updates.html +++ b/updates.html @@ -7,22 +7,25 @@ -
+ -