diff --git a/src/metadata/anilist_anime.db b/src/metadata/anilist_anime.db index 2f98d95..0884581 100644 Binary files a/src/metadata/anilist_anime.db and b/src/metadata/anilist_anime.db differ diff --git a/src/scripts/schedule/schedule.js b/src/scripts/schedule/schedule.js new file mode 100644 index 0000000..3db7d8e --- /dev/null +++ b/src/scripts/schedule/schedule.js @@ -0,0 +1,360 @@ +const ANILIST_API = 'https://graphql.anilist.co'; +const CACHE_NAME = 'waifuboard-schedule-v1'; +const CACHE_DURATION = 5 * 60 * 1000; + +const state = { + currentDate: new Date(), + viewType: 'MONTH', + mode: 'SUB', + loading: false, + abortController: null, + refreshInterval: null +}; + +document.addEventListener('DOMContentLoaded', () => { + renderHeader(); + fetchSchedule(); + + state.refreshInterval = setInterval(() => { + console.log("Auto-refreshing schedule..."); + fetchSchedule(true); + }, CACHE_DURATION); +}); + +async function getCache(key) { + try { + const cache = await caches.open(CACHE_NAME); + + const response = await cache.match(`/schedule-cache/${key}`); + + if (!response) return null; + + const cached = await response.json(); + const age = Date.now() - cached.timestamp; + + if (age < CACHE_DURATION) { + console.log(`[Cache Hit] Loaded ${key} (Age: ${Math.round(age / 1000)}s)`); + return cached.data; + } + + console.log(`[Cache Stale] ${key} expired.`); + + cache.delete(`/schedule-cache/${key}`); + return null; + } catch (e) { + console.error("Cache read failed", e); + return null; + } +} + +async function setCache(key, data) { + try { + const cache = await caches.open(CACHE_NAME); + const payload = JSON.stringify({ + timestamp: Date.now(), + data: data + }); + + const response = new Response(payload, { + headers: { 'Content-Type': 'application/json' } + }); + await cache.put(`/schedule-cache/${key}`, response); + } catch (e) { + console.warn("Cache write failed", e); + } +} + +function getCacheKey() { + if (state.viewType === 'MONTH') { + return `M_${state.currentDate.getFullYear()}_${state.currentDate.getMonth()}`; + } else { + const start = getWeekStart(state.currentDate); + return `W_${start.toISOString().split('T')[0]}`; + } +} + +function navigate(delta) { + if (state.abortController) state.abortController.abort(); + + if (state.viewType === 'MONTH') { + state.currentDate.setMonth(state.currentDate.getMonth() + delta); + } else { + state.currentDate.setDate(state.currentDate.getDate() + (delta * 7)); + } + + renderHeader(); + fetchSchedule(); +} + +function setViewType(type) { + if (state.viewType === type) return; + state.viewType = type; + + document.getElementById('btnViewMonth').classList.toggle('active', type === 'MONTH'); + document.getElementById('btnViewWeek').classList.toggle('active', type === 'WEEK'); + + if (state.abortController) state.abortController.abort(); + + renderHeader(); + fetchSchedule(); +} + +function setMode(mode) { + if (state.mode === mode) return; + state.mode = mode; + document.getElementById('btnSub').classList.toggle('active', mode === 'SUB'); + document.getElementById('btnDub').classList.toggle('active', mode === 'DUB'); + + fetchSchedule(); +} + +function renderHeader() { + const options = { month: 'long', year: 'numeric' }; + let title = state.currentDate.toLocaleDateString('en-US', options); + + if (state.viewType === 'WEEK') { + const start = getWeekStart(state.currentDate); + const end = new Date(start); + end.setDate(end.getDate() + 6); + + const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + title = `Week of ${startStr} - ${endStr}`; + } + + document.getElementById('monthTitle').textContent = title; +} + +function getWeekStart(date) { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.setDate(diff)); +} + +async function fetchSchedule(forceRefresh = false) { + const key = getCacheKey(); + + if (!forceRefresh) { + const cachedData = await getCache(key); + if (cachedData) { + renderGrid(cachedData); + updateAmbient(cachedData); + return; + } + } + + if (state.abortController) state.abortController.abort(); + state.abortController = new AbortController(); + const signal = state.abortController.signal; + + if (!forceRefresh) setLoading(true); + + let startTs, endTs; + if (state.viewType === 'MONTH') { + const year = state.currentDate.getFullYear(); + const month = state.currentDate.getMonth(); + startTs = Math.floor(new Date(year, month, 1).getTime() / 1000); + endTs = Math.floor(new Date(year, month + 1, 0, 23, 59, 59).getTime() / 1000); + } else { + const start = getWeekStart(state.currentDate); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 7); + startTs = Math.floor(start.getTime() / 1000); + endTs = Math.floor(end.getTime() / 1000); + } + + const query = ` + query ($start: Int, $end: Int, $page: Int) { + Page(page: $page, perPage: 50) { + pageInfo { hasNextPage } + airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) { + airingAt + episode + media { + id + title { userPreferred english } + coverImage { large } + bannerImage + isAdult + countryOfOrigin + popularity + } + } + } + } + `; + + let allData = []; + let page = 1; + let hasNext = true; + let retries = 0; + + try { + while (hasNext && page <= 6) { + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + + try { + const res = await fetch(ANILIST_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + query, + variables: { start: startTs, end: endTs, page } + }), + signal: signal + }); + + if (res.status === 429) { + if (retries > 2) throw new Error("Rate Limited"); + console.warn("429 Hit. Waiting..."); + await delay(4000); + retries++; + continue; + } + + const json = await res.json(); + if (json.errors) throw new Error("API Error"); + + const data = json.data.Page; + allData = [...allData, ...data.airingSchedules]; + hasNext = data.pageInfo.hasNextPage; + page++; + + await delay(600); + + } catch (e) { + if (e.name === 'AbortError') throw e; + console.error(e); + break; + } + } + + if (!signal.aborted) { + + await setCache(key, allData); + renderGrid(allData); + updateAmbient(allData); + } + + } catch (e) { + if (e.name !== 'AbortError') console.error("Fetch failed:", e); + } finally { + if (!signal.aborted) { + setLoading(false); + state.abortController = null; + } + } +} + +function renderGrid(data) { + const grid = document.getElementById('daysGrid'); + grid.innerHTML = ''; + + let items = data.filter(i => !i.media.isAdult && i.media.countryOfOrigin === 'JP'); + if (state.mode === 'DUB') { + items = items.filter(i => i.media.popularity > 20000); + } + + if (state.viewType === 'MONTH') { + const year = state.currentDate.getFullYear(); + const month = state.currentDate.getMonth(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + let firstDayIndex = new Date(year, month, 1).getDay() - 1; + if (firstDayIndex === -1) firstDayIndex = 6; + + for (let i = 0; i < firstDayIndex; i++) { + const empty = document.createElement('div'); + empty.className = 'day-cell empty'; + grid.appendChild(empty); + } + + for (let day = 1; day <= daysInMonth; day++) { + const dateObj = new Date(year, month, day); + renderDayCell(dateObj, items, grid); + } + } else { + const start = getWeekStart(state.currentDate); + for (let i = 0; i < 7; i++) { + const dateObj = new Date(start); + dateObj.setDate(start.getDate() + i); + renderDayCell(dateObj, items, grid); + } + } +} + +function renderDayCell(dateObj, items, grid) { + const cell = document.createElement('div'); + cell.className = 'day-cell'; + + if (state.viewType === 'WEEK') cell.style.minHeight = '300px'; + + const day = dateObj.getDate(); + const month = dateObj.getMonth(); + const year = dateObj.getFullYear(); + + const now = new Date(); + if (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) { + cell.classList.add('today'); + } + + const dayEvents = items.filter(i => { + const eventDate = new Date(i.airingAt * 1000); + return eventDate.getDate() === day && eventDate.getMonth() === month && eventDate.getFullYear() === year; + }); + + dayEvents.sort((a, b) => b.media.popularity - a.media.popularity); + + if (dayEvents.length > 0) { + const top = dayEvents[0].media; + const bg = document.createElement('div'); + bg.className = 'cell-backdrop'; + bg.style.backgroundImage = `url('${top.coverImage.large}')`; + cell.appendChild(bg); + } + + const header = document.createElement('div'); + header.className = 'day-header'; + header.innerHTML = ` + ${day} + Today + `; + cell.appendChild(header); + + const list = document.createElement('div'); + list.className = 'events-list'; + + dayEvents.forEach(evt => { + const title = evt.media.title.english || evt.media.title.userPreferred; + const link = `/anime/${evt.media.id}`; + + const chip = document.createElement('a'); + chip.className = 'anime-chip'; + chip.href = link; + chip.innerHTML = ` + ${title} + Ep ${evt.episode} + `; + list.appendChild(chip); + }); + + cell.appendChild(list); + grid.appendChild(cell); +} + +function setLoading(bool) { + state.loading = bool; + const loader = document.getElementById('loader'); + if (bool) loader.classList.add('active'); + else loader.classList.remove('active'); +} + +function delay(ms) { return new Promise(r => setTimeout(r, ms)); } + +function updateAmbient(data) { + if (!data || !data.length) return; + const top = data.reduce((prev, curr) => (prev.media.popularity > curr.media.popularity) ? prev : curr); + const img = top.media.bannerImage || top.media.coverImage.large; + if (img) document.getElementById('ambientBg').style.backgroundImage = `url('${img}')`; +} \ No newline at end of file diff --git a/src/views/views.routes.ts b/src/views/views.routes.ts index 7b34e2e..9877a25 100644 --- a/src/views/views.routes.ts +++ b/src/views/views.routes.ts @@ -14,6 +14,11 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(stream); }); + fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html')); + reply.type('text/html').send(stream); + }); + fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => { const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime.html')); reply.type('text/html').send(stream); diff --git a/views/css/schedule/schedule.css b/views/css/schedule/schedule.css new file mode 100644 index 0000000..5508a9e --- /dev/null +++ b/views/css/schedule/schedule.css @@ -0,0 +1,453 @@ +:root { + + --bg-base: #09090b; + --bg-surface: #121215; + --bg-glass: rgba(18, 18, 21, 0.8); + --bg-cell: #0c0c0e; + --accent: #8b5cf6; + --accent-glow: rgba(139, 92, 246, 0.3); + --text-primary: #ffffff; + --text-secondary: #a1a1aa; + --border: rgba(255, 255, 255, 0.08); + --radius-md: 12px; + --radius-lg: 24px; + --nav-height: 80px; + --font-main: 'Inter', sans-serif; +} + +* { + box-sizing: border-box; + outline: none; +} + +body { + margin: 0; + background-color: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-main); + overflow: hidden; + height: 100vh; + display: flex; + flex-direction: column; +} + +.navbar { + height: var(--nav-height); + display: flex; + align-items: center; + justify-content: center; + padding: 0 2rem; + background: var(--bg-glass); + backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + z-index: 100; + flex-shrink: 0; + position: relative; +} + +.nav-brand { + position: absolute; + left: 2rem; + font-weight: 900; + font-size: 1.4rem; + display: flex; + align-items: center; + gap: 0.8rem; + color: white; + text-decoration: none; + letter-spacing: -0.02em; +} + +.brand-icon { + width: 36px; + height: 36px; + background: transparent; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + +} + +.brand-icon img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.nav-center { + display: flex; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.03); + padding: 0.3rem; + border-radius: 999px; + border: 1px solid var(--border); +} + +.nav-button { + background: transparent; + border: none; + color: var(--text-secondary); + padding: 0.5rem 1.2rem; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: all 0.2s ease; +} + +.nav-button:hover { + color: white; + background: rgba(255, 255, 255, 0.05); +} + +.nav-button.active { + background: rgba(255, 255, 255, 0.1); + color: white; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.ambient-bg { + position: absolute; + inset: 0; + z-index: -1; + background-size: cover; + background-position: center; + opacity: 0.06; + filter: blur(120px) saturate(1.2); + transition: background-image 1s ease-in-out; + pointer-events: none; +} + +.calendar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0 2rem 2rem 2rem; + max-width: 1920px; + width: 100%; + margin: 0 auto; +} + +.calendar-controls { + padding: 1.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.month-selector { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.month-title { + font-size: 2.2rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(to right, #fff, #a1a1aa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + min-width: 350px; +} + +.icon-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + width: 44px; + height: 44px; + border-radius: 12px; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: 0.2s; +} + +.icon-btn:hover { + background: var(--accent); + border-color: var(--accent); + transform: translateY(-2px); +} + +.controls-right { + display: flex; + gap: 1rem; +} + +.view-toggles { + display: flex; + background: #0f0f12; + padding: 4px; + border-radius: 99px; + border: 1px solid var(--border); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.toggle-item { + padding: 10px 24px; + border-radius: 99px; + border: none; + background: transparent; + color: var(--text-secondary); + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-item.active { + background: var(--accent); + color: white; + box-shadow: 0 2px 10px var(--accent-glow); +} + +.calendar-board { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.weekdays-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + flex-shrink: 0; +} + +.weekday-header { + padding: 16px; + text-align: center; + text-transform: uppercase; + font-size: 0.75rem; + font-weight: 800; + color: var(--text-secondary); + letter-spacing: 0.1em; + border-right: 1px solid var(--border); +} + +.weekday-header:last-child { + border-right: none; +} + +.days-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; + overflow-y: auto; + flex: 1; + + grid-auto-rows: minmax(180px, 1fr); + background: var(--bg-base); +} + +.day-cell { + position: relative; + background: var(--bg-cell); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 12px; + transition: background 0.2s; + overflow: hidden; +} + +.day-cell:nth-child(7n) { + border-right: none; +} + +.day-cell.empty { + background: rgba(0, 0, 0, 0.2); + pointer-events: none; +} + +.day-cell:hover { + background: #16161a; +} + +.day-cell.today { + background: rgba(139, 92, 246, 0.03); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.day-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + z-index: 2; + pointer-events: none; +} + +.day-number { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-secondary); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.day-cell.today .day-number { + background: var(--accent); + color: white; + box-shadow: 0 0 15px var(--accent-glow); +} + +.today-label { + font-size: 0.65rem; + font-weight: 800; + color: var(--accent); + letter-spacing: 0.05em; + text-transform: uppercase; + display: none; +} + +.day-cell.today .today-label { + display: block; +} + +.events-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + z-index: 2; + padding-right: 4px; +} + +.events-list::-webkit-scrollbar { + width: 4px; +} + +.events-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.anime-chip { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 8px 10px; + border-radius: 8px; + font-size: 0.8rem; + color: #d4d4d8; + text-decoration: none; + transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.anime-chip::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--accent); + opacity: 0; + transition: opacity 0.2s; +} + +.anime-chip:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + padding-left: 14px; +} + +.anime-chip:hover::before { + opacity: 1; +} + +.chip-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + margin-right: 8px; +} + +.chip-ep { + font-size: 0.7rem; + font-weight: 700; + color: var(--text-secondary); + background: rgba(0, 0, 0, 0.4); + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.cell-backdrop { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0; + transition: opacity 0.4s ease; + filter: grayscale(100%) brightness(0.25); + z-index: 1; + pointer-events: none; +} + +.day-cell:hover .cell-backdrop { + opacity: 1; +} + +.loader { + position: fixed; + bottom: 30px; + right: 30px; + background: #18181b; + border: 1px solid var(--border); + padding: 12px 24px; + border-radius: 99px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + transform: translateY(100px); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 1000; +} + +.loader.active { + transform: translateY(0); +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s infinite linear; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/views/schedule.html b/views/schedule.html new file mode 100644 index 0000000..8f5c3df --- /dev/null +++ b/views/schedule.html @@ -0,0 +1,85 @@ + + +
+ + +