Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 128 additions & 26 deletions anime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -144,21 +165,25 @@ 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 = {
seasonal: ["📅 Seasonal Anime", "Browse your favorite animes by season"],
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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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 || "";
Expand All @@ -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);
Expand Down Expand Up @@ -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 || "";
Expand All @@ -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);
Expand All @@ -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 || "";
Expand All @@ -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 || "";
Expand All @@ -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);
}
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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(); });
Expand Down Expand Up @@ -833,6 +933,8 @@ function populateYearFilter() {
}

// Initialize
const savedTheme = localStorage.getItem("theme");
if (savedTheme) setTheme(savedTheme);
populateYearFilter();
loadFilters();
renderFavorites();
Expand Down
1 change: 1 addition & 0 deletions src/style.css
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
@import "tailwindcss";
@custom-variant light (&:where(.light, .light *));
Loading