AnimeLegacy
A personal anime tracker with the visual discipline of a modern editorial product. A spiritual successor to MyAnimeList — search, track, and chronicle every series you watch.
Built with Next.js 14, Firebase, and the Jikan + AniList APIs. Dark-first, typography-led, designed to stay out of your way while you decide what to watch next.
Features
- Seasonal discovery — browse current & past seasons, with filters for genre, format, and sort.
- Personal list — track what you're watching, planning, completed, on hold, or dropped. Real-time sync via Firestore.
- Smart progress — episode counters clamp to totals; airing shows can't be marked "completed" until they finish.
- Ratings & reviews — rate 1–5 stars (half-star precision) and write freeform reviews. Split into two focused modals so rating is a 2-second action and review is a dedicated writing surface. Restricted to
watching / completed / on_hold / dropped statuses.
- Favorites — curate up to 10 favourites in each of four buckets (anime, characters, voice actors, studios), featured on your profile.
- MyAnimeList import — paste your MAL username and pull your full list (every status, progress, score) plus favourite anime and favourite characters in one step. Skip-silent merge leaves existing entries intact, and the 10-per-category favourite cap is honoured.
- Character & voice actor pages — biographies, appearances, and cross-linked cast. The desktop
/characters index features a "Character of the month" hero card on top, deterministically rotated from the top-25 by Jikan favourites ((year*12+month) % 25) with quote, source-anime episode count, role and Jikan ranking.
- Studios index & detail — every producer from Jikan, filtered down to actual animation studios only (Jikan lumps animators, financiers and licensors together; a client-side sweep classifies by role). Each studio has a dedicated detail page with a filmography timeline grouped by year, KPI strip, genre-mix breakdown, score histogram, upcoming releases, and related studios.
- Discover page (
/search) — dual-mode. With no query: editorial feature (top-rated primary + two under-the-radar secondary), 6 curated mood collections, an experimental Vibe Finder with pace/tone/length sliders that rank anime by Euclidean distance on a genre-derived coord space, hidden-gem cards (score ≥ 8 but less watched), and a clickable genre rail with real Jikan counts. With a query/genre/mood filter: standard search results grid with removable filter chips.
- Profile — live stats (episodes watched, days spent, mean score), top genres, seasonal progress, and activity feed.
- Global search — debounced Jikan search with cover preview, keyboard navigation, and ⌘K shortcut.
- Auth — email/password + Google OAuth with password strength rules and account deletion.
- Calendar — weekly schedule grid (day × hour) of currently airing anime, rebucketed to viewer timezone post-hydration so a JST 23:00 broadcast lands in the right cell for PT viewers.
- Voice actor pages — top voice actors index + per-actor detail with filmography and signature roles, links cross-wired from character pages.
- Multilingual —
react-switch-lang HOC with en / pt / es / fr. Synopses and biographies are translated on-demand via MyMemory and cached in Firestore per (docId, lang).
- Install as an app (mobile) — full PWA scaffolding:
manifest.webmanifest, service worker for installability + offline fallback page, <InstallAppButton> in the mobile profile menu that triggers Chrome's native install prompt on Android and shows step-by-step Safari instructions on iOS. Hidden on desktop by design (no value-add over a browser tab).
Tech stack
| Layer | Choice |
|---|
| Framework | Next.js 14 (Pages Router) |
| Language | JavaScript (ES2022) |
| Styling | CSS Modules + design tokens (styles/tokens.css) |
| UI primitives | Custom (components/ui/) + Lucide React icons |
| Typography | Space Grotesk (display) · Plus Jakarta Sans (body) · JetBrains Mono (numerics) |
| Auth & DB | Firebase Auth + Firestore |
| Anime data | Jikan v4 (MyAnimeList) + AniList GraphQL for higher-res covers |
| Translations | MyMemory (free tier, no key) — cached per (text, lang) in Firestore |
| i18n | react-switch-lang — en/pt/es/fr, dot-notation keys, flag CDN via flagcdn.com |
| Error tracking | Sentry (via @sentry/nextjs + instrumentation files) — silent transactions (sample rate 0), unhandled exceptions only |
| Deployment | OVHcloud VPS + Caddy reverse proxy + systemd |
Quick start
Prerequisites
- Node.js 20+ (LTS) — pinned in
.nvmrc / .node-version
- pnpm 9+ — this project uses pnpm (not npm/yarn)
- A Firebase project (Auth + Firestore enabled)
- Java 17+ — only required to run the test suite (Firestore emulator). Skip if you only intend to
pnpm dev / pnpm build.
Install
pnpm install
Configure environment
Copy .env.local.example to .env.local and fill in your Firebase credentials.
Manual setup — .env.local:
# Client — public by design (ship to browser, secured by firestore.rules)
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
# Optional — bucket key for `pnpm gen-trans` (MyMemory API).
# With an email the daily quota is 50 000 words/day; anonymous is 5 000.
# MyMemory does not validate this — it is only used as a quota bucket.
MYMEMORY_EMAIL=
# Optional — Sentry error tracking. Leave blank to disable.
# When set, instrumentation-client.js + instrumentation.js initialise Sentry
# with sample rate 0 (no transactions/replays) — only unhandled exceptions.
NEXT_PUBLIC_SENTRY_DSN=
Run locally
pnpm dev
Open http://localhost:3000.
Other scripts
pnpm build # Production build
pnpm start # Start production server
pnpm lint # Oxlint (project has a zero-error discipline)
pnpm format # Oxfmt
pnpm test # Vitest against the Firestore emulator (needs Java 17+)
pnpm test:watch # Same, in watch mode
pnpm analyze # Build with @next/bundle-analyzer — opens treemap
pnpm seed:firestore # Seed anime catalog (requires service-account JSON)
pnpm gen-trans # Auto-translate new en.json keys into pt/es/fr via MyMemory
pnpm gen-icons # Re-render PWA icons + poster placeholder from public/brand/iris-mark.svg (192/512/180/maskable + 480×720 logo_no_text.png)
Project structure
AnimeLegacy/
├── components/
│ ├── anime/ # MobileAnimeDetail (hero + meta + progress card), TrailerEmbed (lite-youtube facade)
│ ├── auth/ # AuthShell, AuthPage, EmailAuthForm, GoogleAuthButton, ProfileCompletionModal
│ ├── calendar/ # CalendarCell — single (day × hour) slot entry; MobileCalendar
│ ├── cards/ # PosterCard, DetailedCard, HorizontalRow
│ ├── characters/ # CharacterOfMonthHero (desktop /characters monthly featured card), MobileCharacters (index 3-col grid), MobileCharacterDetail (hero + KPIs + appearances + voices + bio)
│ ├── collections/ # MobileCollections (shell with Coming Soon)
│ ├── discover/ # EditorialFeature, MoodGrid, VibeFinder, HiddenGems, GenreRail, BecauseYouLiked, SurpriseMe, AiringThisWeek, FilterBanner
│ ├── home/ # MobileHome (hero carousel + airing strip + highlights)
│ ├── layout/ # Sidebar, Header, HeaderSearch, LanguageSwitcher, Layout, MobileTopBar (logo + search/lang/bell/avatar), RouteProgress (top progress bar on route changes)
│ ├── modals/ # Modal (shared bottom-sheet on mobile), AddToListModal, RatingReviewModal
│ ├── myList/ # MobileMyList (KPI strip + tabs + status rows + sort menu)
│ ├── notifications/ # NotificationsButton (bell + badge), NotificationsPanel (categorized list, mark-all-read, clear-all)
│ ├── profile/ # ProfileHeader, MobileProfileHeader, ProfileEmptyState, KpiRow, ActivityTimeline, FavoritePosterGrid, FavoritesStrip, FavoriteCharactersStrip, FavoriteVoicesStrip, FavoriteStudiosStrip, TopFavoritesCards, ReorderControls, ReviewsPanel, ReviewRow, RatingDistribution, RecentEntriesTable, SeasonRing, StreakCard, WatchHeatmap, EditProfileModal (uses shared Modal), DangerZone, GenreBars
│ ├── search/ # MobileSearch (empty state + categorized results), MobileCatalogue (anime catalogue with type tabs/genre chips/best match/grid)
│ ├── seasons/ # EditorPickCard, KpiRow, SeasonTabs, TopThreeSection, MobileSeasons (year dropdown + season tabs + editorial + 3-col grid)
│ ├── studios/ # StudioCard, FeaturedStudio, StudioFilterBar, StudiosHeader, MobileStudios (index era tabs + cards), MobileStudioDetail (logo hero + KPI strip + works grid + about)
│ ├── ui/ # Button, IconButton, Logo, StatusBadge, RatingDisplay, ProgressBar, Skeleton, ComingSoon (with mobileSlot), Dropdown, MultiDropdown, LazyOnVisible (IntersectionObserver gate for below-the-fold sections), InstallAppButton (PWA install entry in mobile profile menu)
│ ├── voices/ # MobileVoices (A-Z letter index + initials avatars), MobileVoiceDetail (avatar hero + 4-cell KPI grid + recent roles + bio)
│ └── ErrorBoundary.jsx # Sentry-wired top-level boundary (wraps `<App>`)
├── hooks/ # useAuth, useMyList, useProfileData, useUserProfile, useTranslatedText, useFavoriteToggle, useFavoriteIds, useBodyScrollLock, useDragReorder, useNotifications (composes user list + favorites + notificationState + lazy schedule/relations fetch), usePwaInstall (capture beforeinstallprompt + iOS detection + isMobile gating)
├── lib/
│ ├── firebase/ # client.js + admin.js + authStateStore, userProfileStore, userListStore, notificationStateStore (lastReadAt + dismissedIds + seenEpisodes) — subscriber stores for listener consolidation + createSubscriberStore factory
│ ├── services/ # _cache.js (TTL+inflight), jikan.js, anilist.js (per-ID inflight, includes score/popularity/favourites/year), mymemory.js (translation), userProfile, userAnime, animeCatalog, userList, favoriteCharacters/Voices/Studios, favoriteOrder (shared batch personalRank writer), categorySearch (4-section parallel fetch shared by HeaderSearch desktop + MobileSearch)
│ ├── utils/ # anime, season, media, time, cardShape, synopsis, text (truncateText/firstSentence), listTransitions, profileActivity, profileStats, heatmap, rating, reorder, debounce, reviewsView, rateLimit, authErrors, discoverPayload, discoverFilter, discoverRecs, vibeFinder, studio, studioAccent, studioStats, calendarSchedule, charLocalize, malImport, airingThisWeek, seasonHero, sessionCache, router, userDisplay (userInitials + nameInitials), chunk, notifications (compute logic for airing/finished/newEpisode/sequel + optimistic-state helpers consumed by useNotifications), voiceRoles (year + sortVoiceRolesByPopularity), pwaPlatform (UA → isIos/isAndroid/isMobile, pure), characterOfMonth (deterministic monthly pick + role tiebreaker)
│ ├── constants/ # flags.js (SUPPORTED_LANGUAGES, flagcdn URLs)
│ └── constants.js # FAVORITE_LIMIT, MAX_AVATAR_SIZE_*, PASSWORD_RULES, isValidEmail
├── pages/
│ ├── api/ # delete-account, anime-recs, studio-posters, translate-synopsis, mal-import, notifications-data (schedules + relations for notification computation)
│ ├── anime/[id] # Anime detail (hero, characters, recs)
│ ├── characters/ # index + [id] detail
│ ├── voices/ # index + [id] detail (voice actors)
│ ├── studios/ # index + [id] detail
│ ├── seasons/ # index (redirect) + [year] (cinematic browse)
│ ├── import/mal.js # MAL username importer (preview + commit)
│ ├── index.js # Home — hero + continue watching + trending
│ ├── my-list.js # Personal list (tabs, list/grid view)
│ ├── profile.js # User profile
│ ├── search.js # Discover (results + curated modes)
│ ├── calendar.js # Weekly schedule grid
│ ├── movies.js # Top rated movies
│ └── sign-in|sign-up|forgot-password|reset-password|privacy|license
├── lang/ # en.json (source of truth) + pt.json, es.json, fr.json
├── styles/ # Global tokens + per-page CSS modules
├── tests/ # Vitest suite (Firestore emulator + pure unit tests)
├── instrumentation.js # Sentry server init + onRequestError
├── instrumentation-client.js# Sentry browser init (silent transactions, ignored patterns)
├── firestore.rules
├── firestore.indexes.json
├── firebase.json # Emulator port config
└── next.config.js
Route map
| Route | Description |
|---|
/ | Home — hero carousel + continue watching + trending rows |
/seasons | Redirects to current year |
/seasons/[year] | Seasonal browse with filters |
/anime/[id] | Anime detail, cast, trailer, add-to-list |
/characters/[id] | Character detail, bio, appearances, voice actors |
/search | Discover page — editorial picks, moods, vibe finder, hidden gems, genre rail |
/search?q= / ?genre= / ?mood= | Search results (Jikan) — filterable via query string |
/studios | Studios index with featured studio, filters, and paginated grid |
/studios/[id] | Studio detail — hero, KPIs, filmography timeline, score distribution |
/my-list | Personal list with tabs, list/grid view |
/profile | Profile stats, favorites, reviews, activity |
/movies | Top rated movies, paginated |
/characters | Top characters by favourites count, paginated |
/voices / /voices/[id] | Top voice actors index + per-actor filmography & signature roles |
/calendar | Weekly schedule grid — currently airing anime bucketed by day × hour in viewer TZ |
/import/mal | MAL username importer — list + favourites preview, skip-silent merge |
/collections, /compare | Coming-soon placeholders with roadmap copy (not 404s) |
/sign-in, /sign-up | Auth |
/forgot-password, /reset-password | Recovery flow |
/privacy | GDPR rights + account-deletion contact |
/license | MIT License full text + repo link |
Design system
Tokens live in styles/tokens.css. All components consume them via CSS variables.
Color ramp (dark-first):
--al-ink-0 → --al-ink-5 — surface layers from deepest bg to borders
--al-fg-1 → --al-fg-4 — foreground ramp from primary text to disabled
Accents:
- Primary — cyan/blue gradient (
#6f83ff → #84d9ff)
- Warm — amber (favorites, ratings)
- Collection — violet (in-list state)
- Semantic —
success / danger / warn with soft background variants
Status tokens for the 5 watching states (watching, completed, plan, dropped, paused) each have a dedicated color & badge style.
Typography scale: display-hero (72px) → xs (12px), plus an uppercase-tracked eyebrow for section headers. Monospace numerics are used in progress counters, scores, and rankings for rhythm.
Motion: --dur-1 (120ms) → --dur-4 (420ms), --ease-standard (cubic-bezier(0.2, 0, 0, 1)). Page transitions use the al-rise animation (8px fade-up).
Data flow
┌──────────────┐ ┌───────────────────┐
│ Jikan v4 │────────│ /lib/services/ │
└──────────────┘ │ jikan.js │
│ │────┐
┌──────────────┐ │ anilist.js │ │
│ AniList GQL │────────│ (cover upgrade) │ │
└──────────────┘ └───────────────────┘ │
▼
┌───────────────┐
│ Next.js page │
│ (SSR / CSR) │
└───────┬───────┘
│
┌───────────────┐ │
│ useMyList │◄───────┤
│ useProfile │ │
│ useAuth │ │
└───────┬───────┘ │
│ │
▼ ▼
┌───────────────────────────┐
│ Firestore │
│ users/{uid}/list │
│ users/{uid}/anime │
│ users/{uid}/activity │
│ users/{uid}/favoriteChars│
│ anime/{id} │
│ usernames/{normalized} │
│ characterStats/{id} │
└───────────────────────────┘
- Jikan (
api.jikan.moe/v4) is the main source for anime metadata, characters, and search.
- AniList GraphQL supplements poster/banner URLs with higher-resolution imagery. IDs are resolved by MAL ID in batches of 25.
- Firestore stores per-user state. Listeners are consolidated into shared subscriber stores in
lib/firebase/ (one onSnapshot per (uid, key) fan-out to N React subscribers) so re-renders and cross-component overlap don't multiply listeners. The factory also deep-equals incoming snapshots against the cached value and skips the fan-out if structurally identical, so downstream useMemos stay stable across no-op snapshot churn.
- Request dedup is mandatory — every external getter (Jikan/AniList/MyMemory) routes through an inflight
Map keyed by request id, so N concurrent callers for the same data share one network round-trip. Search-style endpoints layer a session-scoped LRU+TTL cache on top (see HeaderSearch.jsx). See the Request dedup section of CLAUDE.md for the contract any new fetcher has to follow.
- Sentry is wired via
instrumentation.js (server, captureRequestError) and instrumentation-client.js (browser, init + named captureRouterTransitionStart). tracesSampleRate: 0, replays*: 0, autoSessionTracking: false, and beforeSendTransaction: () => null keep the envelope POSTs to just real unhandled exceptions. ErrorBoundary.jsx and each /api/* route call Sentry.captureException explicitly inside their catch blocks rather than using console.error, so caught failures still surface in Sentry. Set NEXT_PUBLIC_SENTRY_DSN in .env.local to enable; absent DSN = silently disabled.
In-memory TTL cache
Both external APIs (Jikan + AniList) are wrapped by a shared cache at lib/services/_cache.js. Each cached getter has a TTL tuned to how often the underlying data actually changes:
| Service / endpoint | TTL | Reason |
|---|
| Jikan — anime / character / person detail | 1h | Biographies and metadata are near-static |
Jikan — /top/anime | 15m | Popularity shifts |
Jikan — /seasons/{year}/{season} | 1h | Season lineup is fixed once published |
Jikan — /schedules | 5m | Broadcast times can change day-of |
Jikan — /anime?q= (search) | not cached | Every query is unique |
| AniList — cover/banner per MAL ID | 6h | Cover URLs are effectively immutable |
The cache lives inside the long-running Node process on the VPS, so it persists for the whole uptime of the systemd service. It serves stale data when the origin fails, so a Jikan 429 burst doesn't blank out popular pages. When the deployment scales horizontally to multiple machines, the cached(key, ttlMs, fetcher) signature is a drop-in replacement slot for Upstash Redis or any shared KV store.
Firestore schema (summary)
users/{uid}
├─ profile data (username, bio, avatarData, timestamps)
├─ list/{animeId} — lightweight entry
├─ anime/{animeId} — full tracked entry with progress + rating
├─ activity/{id} — activity log for feed
└─ favoriteCharacters/{id}
anime/{animeId} — shared catalog cache
usernames/{normalized} — username uniqueness index
characterStats/{id} — global character favorite counters
Security rules live in firestore.rules. Key invariants:
- A user can only read/write under their own
users/{uid} document.
- The favorite limit (10) is enforced in client code; rules protect against direct manipulation.
Account deletion (GDPR Article 17)
/privacy is always available and explains how to request erasure. There are two deletion modes:
- Self-service (automated): requires
FIREBASE_ADMIN_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, and FIREBASE_ADMIN_PRIVATE_KEY set in the production environment (loaded from .env.local on the VPS). When present, the Delete account button in the profile edit modal calls /api/delete-account, which wipes every subcollection under users/{uid}, releases the username reservation, and removes the Firebase Auth user.
- Manual fallback (current default): without those env vars, the API returns
503 and the UI points users at /privacy to request removal via email. See docs/account-deletion.md for the operator runbook — what to click, in what order, to stay GDPR-compliant without automation.
Testing
Integration tests run against the Firestore emulator, so bugs that only show up at the rule level are caught — not just client logic mistakes.
pnpm test # one-shot run (used by CI)
pnpm test:watch # watch mode for local work
Both commands wrap the run with firebase emulators:exec, which starts a throwaway emulator on 127.0.0.1:8080, runs Vitest, and shuts it down. First run downloads the emulator JAR (~50 MB, cached under ~/.cache/firebase/).
What's covered
| File | Scope |
|---|
tests/cleanSynopsis.test.js | Pure regex tests for the MAL/AniList credit stripper ([Written by …], (Source: …)). |
tests/listTransitions.test.js | useMyList state machine — status normalisation for airing shows, progress clamping, favourite-limit enforcement, activity-label derivation across every transition. |
tests/claimUsername.test.js | Transactional username uniqueness — cross-case collisions, idempotent re-claim, rules-only tests that verify firestore.rules forbids impersonation / overwrite / delete even if client logic were buggy. |
tests/upsertUserProfile.test.js | Profile writes — createdAt preserved, usernameLower derivation, avatar merge semantics, impersonation blocked. |
tests/userDataRules.test.js | Firestore rules for all user-scoped subcollections (anime, list, activity, favoriteCharacters) + catalog (anime/{id}) + characterStats/{id} whitelist. |
tests/cache.test.js | Shared service cache helper — hit/miss behaviour, TTL expiry, stale-on-error fallback. |
tests/profileStats.test.js | Pure profile aggregators — computeStats, computeGenres, computeMeanScoreWithSigma, computeRatingHistogram, computeRecentEntries, countCompletedInDays. |
tests/reorder.test.js | mergeReorderedSlice — merges a reordered subset into the full list while preserving the rest of the original order. |
tests/heatmap.test.js | buildHeatmap 26-week × 7-day grid — level thresholds, Firestore Timestamp handling, future-cell marking. |
tests/rating.test.js | toFivePoint / formatFivePoint — MAL 1-10 → app-wide 1-5 conversion with null-safety for missing scores. |
tests/debounce.test.js | debounce — trailing-edge collapse of rapid calls into one, then re-arms after the window. |
|
Adding tests
- Tests live under
tests/ and follow a single *.test.js convention.
- For integration against the emulator, import
newTestEnv from tests/helpers.js (wraps @firebase/rules-unit-testing).
- Functions that depend on Firebase use a DI pattern so tests can pass in an emulator-backed Firestore: e.g.
claimUsernameIn(db, args) vs the public claimUsername(args) that pulls from getFirebaseClient(). Prefer extracting a pure helper when the logic doesn't actually need Firestore.
- Test files run sequentially (
fileParallelism: false) because they all share one emulator instance — running in parallel would let one file's clearFirestore() wipe another file's mid-test state.
CI enforces the suite alongside lint and build (.github/workflows/ci.yml).
Performance & bundle size
There's no hard performance budget yet, but two lightweight signals are in place so regressions don't slip through unnoticed:
Local — pnpm analyze
Runs a production build with @next/bundle-analyzer enabled, then opens a treemap in the browser. Good for finding surprising node_modules that snuck in via an npm upgrade.
pnpm analyze
CI — bundle-size diff
.github/workflows/bundle-size.yml builds the PR branch and main, parses next build output via scripts/bundle-report.cjs, and posts a sticky PR comment with per-route First Load JS diff. ±2% gets flagged. There's no hard gate — the signal is there so regressions aren't silent.
Internationalization
Wrap components with translate() from react-switch-lang — they receive t as a prop and call t('your.dot.notated.key'). Translations live as nested JSON under lang/, one file per language (en.json is the source of truth, pt.json / es.json / fr.json mirror its shape).
Adding a new translatable string:
- Add the key to
lang/en.json.
- Run
pnpm gen-trans — auto-translates new keys into pt/es/fr via MyMemory, removes orphans, throttled to stay under the daily quota. Set MYMEMORY_EMAIL in .env.local to raise the bucket cap from 5 000 → 50 000 words/day.
- In a component:
import { translate } from 'react-switch-lang'; export default translate(MyComponent); then t('your.key') inside.
Auto-translations of long-form synopses can read pt-BR instead of pt-PT — MyMemory falls back to BR when no European-Portuguese match exists. Acceptable for now; not worth re-opening the translation provider question unless quality becomes a blocker.
Adding a new language: copy lang/en.json to lang/{code}.json, add an entry to lib/constants/flags.js, update lib/i18n.js, and add the locale to scripts/generate-translations.cjs::LOCALES.
Releases
Semver discipline. Source of truth: package.json::version + a matching git tag vX.Y.Z + GitHub release. Auto-deploy fires on every push to main regardless of tag, so tags are for human-readable history, not deploys.
| Bump | When |
|---|
patch (0.x.y → 0.x.y+1) | Bug fixes, copy tweaks, lint/comment cleanup, dep bumps with no behaviour change, internal refactors with no user-visible delta |
minor (0.x → 0.x+1.0) | New user-visible features, new pages, redesigns, new translations, new public APIs, non-breaking schema additions |
major (0.x.x → 1.0.0) | Breaking Firestore schema migrations, removed features, auth-flow rewrites, anything that needs a migration step on existing user data |
After landing changes on main with a clean working tree:
pnpm version patch # or: minor / major — bumps package.json + creates the git tag
git push --follow-tags
gh release create vX.Y.Z -F /tmp/vX.Y.Z-notes.md
Release notes are written by hand (group commits by area: Seasons / Discover / etc.) — --generate-notes produces a flat squashed-PR list that's useless when commits land directly on main.
Deployment
AnimeLegacy runs on a single OVHcloud VPS (VPS-1, Beauharnois) with Caddy as the reverse proxy and HTTPS provider, and systemd supervising the Next.js process. A fresh server is provisioned with scripts/vps-setup.sh.
Pipeline:
- Push to
main on GitHub.
- The
.github/workflows/deploy-vps.yml workflow SSHes into the VPS using a deploy-only key (stored in VPS_SSH_KEY secret).
- On the VPS:
git pull, pnpm install --frozen-lockfile, pnpm build, sudo systemctl restart animelegacy.
- Caddy is already running; the new build is picked up on restart with no reverse-proxy config change.
Server layout:
| Component | Path / config |
|---|
| App | /home/duarte/apps/animelegacy/ |
| systemd unit | /etc/systemd/system/animelegacy.service (port 3000) |
| Caddyfile | /etc/caddy/Caddyfile (HTTPS auto via Let's Encrypt) |
| Env vars | /home/duarte/apps/animelegacy/.env.local (Firebase) |
| Backups | OVH automated backup — daily, included with the plan |
DNS: managed at Cloudflare Registrar with two A records (@ and www) pointing to the VPS IP, proxied through Cloudflare. Caddy still issues/renews certs via the Let's Encrypt HTTP-01 challenge, which Cloudflare passes through on port 80.
Edge cache for optimized images: a Cloudflare Cache Rule (Next image cache, expression starts_with(http.request.uri.path, "/_next/image"), Eligible for cache, everything else default) caches next/image output at the edge. Cloudflare doesn't cache these by default because the URLs have no file extension. The edge TTL follows the origin's Cache-Control: public, max-age=2678400 (31 days, from minimumCacheTTL), and the default cache key includes the query string, so every url × w × q variant is stored separately. Check it with curl -sI "https://animelegacy.org/_next/image?url=…&w=640&q=75" | grep cf-cache-status: the first request is MISS and the next one is HIT.
Monitoring:
- Uptime — UptimeRobot free tier pings
https://www.animelegacy.org every 5 minutes. Three consecutive failures → email alert. No external dashboard URL; logs visible in the UptimeRobot console.
- Errors — Sentry captures unhandled exceptions (server
captureRequestError + browser ErrorBoundary + per-/api/* catch). Source maps uploaded on every pnpm build via @sentry/cli.
- Logs —
journalctl -u animelegacy -f on the VPS for the Node process; Caddy access logs at /var/log/caddy/access.log.
Password-reset emails are handled by Firebase Authentication — customize the template in the Firebase console and point the action URL to:
https://animelegacy.org/reset-password
See docs/email-deliverability.md for deliverability tips.
Contributing
This is a personal project, but issues and pull requests are welcome.
Before opening a PR:
- Run
pnpm lint, pnpm test, and pnpm build (CI enforces all three).
- Keep CSS in modules (
styles/*.module.css) and drive colors through the token variables — avoid inline hex values.
- Preserve the Firebase + Jikan boundaries; don't inline external fetches inside components.
- When touching
lib/services/* or Firestore rules, add or update tests under tests/ — rules-only tests are especially valuable since they prove security holds even against a buggy client.
Acknowledgements
- Jikan — the unofficial MyAnimeList API
- AniList — cover & banner imagery
- Lucide — the icon set used across primitives
- Fonts — Space Grotesk, Plus Jakarta Sans, JetBrains Mono (Google Fonts)
License
MIT — see LICENSE for the full text. Use, fork, and learn from the code freely; please retain the copyright notice in any redistributed copies.