diff --git a/.agents/skills/commit-messages/SKILL.md b/.agents/skills/commit-messages/SKILL.md new file mode 100644 index 0000000..781c7b9 --- /dev/null +++ b/.agents/skills/commit-messages/SKILL.md @@ -0,0 +1,90 @@ +--- +name: commit-messages +description: Use when writing a git commit message for the kasane repo, before invoking `git commit` or `gh pr create`. +--- + +# Kasane Commit Messages + +Project-specific commit style for this repo. Apply to every commit. + +## Format + +``` + + +- Short bullet describing one user-facing change +- Another short bullet, with `code` in backticks +- One more if needed +``` + +## Subject line + +- Imperative mood, capitalized first word, no trailing period. +- ≤70 characters. If you can't fit it, split the commit. +- Describe the change, not the files modified. + +Real subjects from this repo: + +- `Split anime/manga routes and enrich manga view with MangaDex` +- `Rebrand to Kasane and overhaul home + series experience` +- `Add Netlify deploy config` +- `Add react-native-worklets dependency to fix Netlify web build` + +## Body + +**Favor bullet points.** Skip the body entirely for trivial commits (typo, dep bump). + +- One change per bullet. No prose paragraphs. +- Keep each bullet short — single line, ≤80 chars where practical. +- Use **backticks** around any function, file path, variable, route, type, or component: `getAnimeFranchise`, `app/manga/[id]/index.tsx`, `media.type`, `/anime/16498`, `AnimeFranchise`. +- Describe user-facing or semantic changes — not file edits. + - ✅ `Add /manga/[id] route with deep linking` → with backticks: `` `/manga/[id]` `` + - ❌ `Modified app/manga/[id]/index.tsx` + +Example body: + +``` +- Add `/anime/[id]` and `/manga/[id]` typed routes +- Redirect legacy `/series/[id]` to the type-specific route +- Layer MangaDex on AniList for per-volume covers in `MangaDetail` +- Walk `relations.edges` to aggregate `AnimeFranchise.totalTvEpisodes` +``` + +## Attribution + +**Never mention any AI agent or assistant in commit messages.** No `Co-Authored-By: Codex`, no Codex / Cursor / Copilot lines, no "🤖 Generated with …" footers. Commits read as if the human wrote them. + +## Invoking `git commit` + +Always pass the message via HEREDOC so newlines and backticks survive: + +```bash +git commit -m "$(cat <<'EOF' +Subject line here + +- First bullet referencing `getMedia` +- Second bullet +EOF +)" +``` + +## Staging + +- Stage files by explicit path; avoid `git add .` / `git add -A`. +- `.Codex/scheduled_tasks.lock` and similar local state are gitignored; don't force-add them. +- Commit `package-lock.json` only when dependencies actually changed. + +## Anti-patterns + +| Don't | Why | +| ---------------------------------------- | --------------------------------------------- | +| `Co-Authored-By: Codex` (or any agent) | Repo style is human-authored attribution only | +| Past tense ("Added X") | Use imperative ("Add X") | +| Period at end of subject | No trailing punctuation | +| Prose paragraphs in body | Prefer bullets | +| Long wrapped bullet lines | Keep each bullet short and single-line | +| Naked symbol names | Always wrap in backticks: `getMedia` | +| Mechanical file lists ("Modified X.tsx") | Describe the change, not the diff | +| `git add .` / `-A` | Risks staging secrets or local state | +| `--no-verify` | Fix the hook failure instead | +| Amend without explicit user ask | Always create a NEW commit | diff --git a/.claude/skills/commit-messages/SKILL.md b/.claude/skills/commit-messages/SKILL.md new file mode 100644 index 0000000..d1e5728 --- /dev/null +++ b/.claude/skills/commit-messages/SKILL.md @@ -0,0 +1,90 @@ +--- +name: commit-messages +description: Use when writing a git commit message for the kasane repo, before invoking `git commit` or `gh pr create`. +--- + +# Kasane Commit Messages + +Project-specific commit style for this repo. Apply to every commit. + +## Format + +``` + + +- Short bullet describing one user-facing change +- Another short bullet, with `code` in backticks +- One more if needed +``` + +## Subject line + +- Imperative mood, capitalized first word, no trailing period. +- ≤70 characters. If you can't fit it, split the commit. +- Describe the change, not the files modified. + +Real subjects from this repo: + +- `Split anime/manga routes and enrich manga view with MangaDex` +- `Rebrand to Kasane and overhaul home + series experience` +- `Add Netlify deploy config` +- `Add react-native-worklets dependency to fix Netlify web build` + +## Body + +**Favor bullet points.** Skip the body entirely for trivial commits (typo, dep bump). + +- One change per bullet. No prose paragraphs. +- Keep each bullet short — single line, ≤80 chars where practical. +- Use **backticks** around any function, file path, variable, route, type, or component: `getAnimeFranchise`, `app/manga/[id]/index.tsx`, `media.type`, `/anime/16498`, `AnimeFranchise`. +- Describe user-facing or semantic changes — not file edits. + - ✅ `Add /manga/[id] route with deep linking` → with backticks: `` `/manga/[id]` `` + - ❌ `Modified app/manga/[id]/index.tsx` + +Example body: + +``` +- Add `/anime/[id]` and `/manga/[id]` typed routes +- Redirect legacy `/series/[id]` to the type-specific route +- Layer MangaDex on AniList for per-volume covers in `MangaDetail` +- Walk `relations.edges` to aggregate `AnimeFranchise.totalTvEpisodes` +``` + +## Attribution + +**Never mention any AI agent or assistant in commit messages.** No `Co-Authored-By: Claude`, no Codex / Cursor / Copilot lines, no "🤖 Generated with …" footers. Commits read as if the human wrote them. + +## Invoking `git commit` + +Always pass the message via HEREDOC so newlines and backticks survive: + +```bash +git commit -m "$(cat <<'EOF' +Subject line here + +- First bullet referencing `getMedia` +- Second bullet +EOF +)" +``` + +## Staging + +- Stage files by explicit path; avoid `git add .` / `git add -A`. +- `.claude/scheduled_tasks.lock` and similar local state are gitignored; don't force-add them. +- Commit `package-lock.json` only when dependencies actually changed. + +## Anti-patterns + +| Don't | Why | +| ---------------------------------------- | --------------------------------------------- | +| `Co-Authored-By: Claude` (or any agent) | Repo style is human-authored attribution only | +| Past tense ("Added X") | Use imperative ("Add X") | +| Period at end of subject | No trailing punctuation | +| Prose paragraphs in body | Prefer bullets | +| Long wrapped bullet lines | Keep each bullet short and single-line | +| Naked symbol names | Always wrap in backticks: `getMedia` | +| Mechanical file lists ("Modified X.tsx") | Describe the change, not the diff | +| `git add .` / `-A` | Risks staging secrets or local state | +| `--no-verify` | Fix the hook failure instead | +| Amend without explicit user ask | Always create a NEW commit | diff --git a/.gitignore b/.gitignore index 7d1f327..92beffd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ src-tauri/Cargo.lock .vscode/ .idea/ -# Claude Code local state -.claude/ +# Claude Code per-machine state (keep skills/agents/settings tracked) +.claude/scheduled_tasks.lock +.claude/*.lock +.claude/settings.local.json diff --git a/app/anime/[id]/index.tsx b/app/anime/[id]/index.tsx index 1f5a127..d6b8d8e 100644 --- a/app/anime/[id]/index.tsx +++ b/app/anime/[id]/index.tsx @@ -2,15 +2,16 @@ import { useMemo, useState } from "react"; import { ActivityIndicator, Image, + Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native"; -import { useLocalSearchParams } from "expo-router"; +import { Link, useLocalSearchParams } from "expo-router"; import { useQuery } from "@tanstack/react-query"; -import { getMedia } from "@/api/anilist"; +import { getAnimeFranchise, getMedia, hasAnimeSequels } from "@/api/anilist"; import { getMangaDexInfoByAniListId } from "@/api/mangadex"; import { buildSyntheticMapping, @@ -22,6 +23,7 @@ import { EpisodeChapterRail } from "@/components/EpisodeChapterRail"; import { Footer } from "@/components/Footer"; import { Paragraph } from "@/components/Paragraph"; import { formatAniListDate } from "@/data/format"; +import type { AnimeFranchise } from "@/types"; import { FONT } from "@/theme"; export default function AnimeDetail() { @@ -34,6 +36,15 @@ export default function AnimeDetail() { enabled: !Number.isNaN(mediaId), }); + const sequels = !!media && hasAnimeSequels(media); + + const { data: franchise } = useQuery({ + queryKey: ["franchise", mediaId], + queryFn: () => getAnimeFranchise(mediaId), + enabled: sequels, + staleTime: 24 * 60 * 60 * 1000, + }); + const curatedMapping = useMemo( () => findMappingByMediaId(mediaId), [mediaId], @@ -114,6 +125,12 @@ export default function AnimeDetail() { ? ` · ${formatAniListDate(media.startDate)}` : ""} + {franchise && franchise.tvSeasonCount > 1 ? ( + + Franchise total: {franchise.totalTvEpisodes} TV eps across{" "} + {franchise.tvSeasonCount} seasons + + ) : null} {media.description && ( {media.description.replace(/<[^>]+>/g, "")} @@ -122,6 +139,10 @@ export default function AnimeDetail() { + {franchise && franchise.seasons.length > 1 ? ( + + ) : null} + {mapping ? ( <> Episode ↔ Chapter map @@ -161,6 +182,52 @@ export default function AnimeDetail() { ); } +function SeasonsList({ + franchise, + currentId, +}: { + franchise: AnimeFranchise; + currentId: number; +}) { + return ( + + Seasons & entries + + {franchise.seasons.map((s) => { + const isCurrent = s.id === currentId; + return ( + + [ + styles.seasonCard, + isCurrent && styles.seasonCardActive, + { opacity: pressed ? 0.6 : hovered ? 0.9 : 1 }, + ]} + > + + {s.title} + + + {s.format ?? "—"} + {s.episodes ? ` · ${s.episodes} eps` : ""} + {s.year ? ` · ${s.year}` : ""} + + {isCurrent ? ( + VIEWING + ) : null} + + + ); + })} + + + ); +} + function QuickLookup({ mapping, }: { @@ -307,4 +374,45 @@ const styles = StyleSheet.create({ fontFamily: FONT.regular, }, lookupResult: { color: "#7c5cff", fontSize: 13, fontFamily: FONT.bold }, + franchiseTotal: { + color: "#7c5cff", + fontSize: 13, + letterSpacing: -0.2, + fontFamily: FONT.semibold, + paddingTop: 2, + }, + seasons: { gap: 10, paddingTop: 4 }, + seasonGrid: { flexDirection: "row", flexWrap: "wrap", gap: 10 }, + seasonCard: { + width: 220, + padding: 12, + backgroundColor: "#17181b", + borderLeftWidth: 3, + borderLeftColor: "#7c5cff", + gap: 6, + }, + seasonCardActive: { + backgroundColor: "#1f1a2e", + borderLeftColor: "#ffd65c", + }, + seasonCardTitle: { + color: "#f5f5f5", + fontSize: 14, + lineHeight: 18, + fontFamily: FONT.semibold, + letterSpacing: -0.2, + }, + seasonCardMeta: { + color: "#9aa0a6", + fontSize: 11, + letterSpacing: 0.8, + textTransform: "uppercase", + fontFamily: FONT.semibold, + }, + seasonCardCurrent: { + color: "#ffd65c", + fontSize: 10, + letterSpacing: 1.4, + fontFamily: FONT.bold, + }, }); diff --git a/src/api/anilist.ts b/src/api/anilist.ts index 32795ac..a626260 100644 --- a/src/api/anilist.ts +++ b/src/api/anilist.ts @@ -1,6 +1,11 @@ import { GraphQLClient, gql } from "graphql-request"; import { applySearchAlias } from "@/data/searchAliases"; -import type { AniListMedia, MediaType } from "@/types"; +import type { + AniListMedia, + AnimeFranchise, + FranchiseSeason, + MediaType, +} from "@/types"; const client = new GraphQLClient("https://graphql.anilist.co"); @@ -22,7 +27,7 @@ const MEDIA_FIELDS = ` relations { edges { relationType(version: 2) - node { id type format } + node { id type format episodes } } } `; @@ -224,3 +229,115 @@ export async function getMediaByIds(ids: number[]): Promise { ); return data.Page.media; } + +const FRANCHISE_NODE_QUERY = gql` + query FranchiseNode($ids: [Int]) { + Page(perPage: 50) { + media(id_in: $ids, type: ANIME) { + id + title { + romaji + english + } + format + episodes + startDate { + year + } + relations { + edges { + relationType(version: 2) + node { + id + type + } + } + } + } + } + } +`; + +type FranchiseRawNode = { + id: number; + title: { romaji: string; english: string | null }; + format: string | null; + episodes: number | null; + startDate: { year: number | null }; + relations: { + edges: { relationType: string; node: { id: number; type: MediaType } }[]; + }; +}; + +const FRANCHISE_RELATIONS = new Set([ + "SEQUEL", + "PREQUEL", + "PARENT", + "SIDE_STORY", +]); + +async function collectFranchiseNodes( + frontier: number[], + visited: Map, +): Promise { + const ids = frontier.filter((id) => !visited.has(id)); + if (ids.length === 0) return Array.from(visited.values()); + + const data = await client.request<{ Page: { media: FranchiseRawNode[] } }>( + FRANCHISE_NODE_QUERY, + { ids }, + ); + const nextVisited = data.Page.media.reduce( + (acc, node) => acc.set(node.id, node), + new Map(visited), + ); + const next = data.Page.media.flatMap((node) => + node.relations.edges + .filter( + (edge) => + FRANCHISE_RELATIONS.has(edge.relationType) && + edge.node.type === "ANIME" && + !nextVisited.has(edge.node.id), + ) + .map((edge) => edge.node.id), + ); + return collectFranchiseNodes([...new Set(next)], nextVisited); +} + +export async function getAnimeFranchise( + rootId: number, +): Promise { + const nodes = await collectFranchiseNodes([rootId], new Map()); + + const seasons: FranchiseSeason[] = nodes + .map((n) => ({ + id: n.id, + title: n.title.english ?? n.title.romaji, + romajiTitle: n.title.romaji, + format: n.format, + episodes: n.episodes, + year: n.startDate.year, + })) + .sort((a, b) => (a.year ?? 9999) - (b.year ?? 9999)); + + const tvSeasons = seasons.filter((s) => s.format === "TV"); + const totalTvEpisodes = tvSeasons.reduce( + (sum, s) => sum + (s.episodes ?? 0), + 0, + ); + + return { + rootId, + seasons, + totalTvEpisodes, + tvSeasonCount: tvSeasons.length, + }; +} + +export function hasAnimeSequels(media: AniListMedia): boolean { + if (media.type !== "ANIME") return false; + const edges = media.relations?.edges ?? []; + return edges.some( + (e) => e.relationType === "SEQUEL" && e.node.type === "ANIME", + ); +} diff --git a/src/components/SeriesCard.tsx b/src/components/SeriesCard.tsx index 2aacd6d..3cb6ef0 100644 --- a/src/components/SeriesCard.tsx +++ b/src/components/SeriesCard.tsx @@ -1,8 +1,10 @@ import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import { Link } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; import type { SeriesBadge, SeriesEntry } from "@/types"; import { FONT } from "@/theme"; import { findMappingByMediaId } from "@/data"; +import { getAnimeFranchise, hasAnimeSequels } from "@/api/anilist"; import { usePreferences } from "@/state/preferences"; import { useSeriesProgress } from "@/state/progress"; @@ -52,10 +54,25 @@ export function SeriesCard({ entry }: { entry: SeriesEntry }) { const showProgressBar = (hasAnime && animeFrac !== null) || (hasManga && mangaFrac !== null); + const sequels = !!anime && hasAnimeSequels(anime); + const { data: franchise } = useQuery({ + queryKey: ["franchise", anime?.id ?? 0], + queryFn: () => getAnimeFranchise(anime?.id ?? 0), + enabled: sequels, + staleTime: 24 * 60 * 60 * 1000, + }); + const franchiseLabel = + mappedEpisodeCount == null && + franchise && + franchise.tvSeasonCount > 1 && + franchise.totalTvEpisodes > 0 + ? `${franchise.totalTvEpisodes} eps · ${franchise.tvSeasonCount} seasons` + : null; + const parts: string[] = []; if (anime || badge === "anime-only") { const eps = mappedEpisodeCount ?? anime?.episodes ?? null; - parts.push(eps ? `${eps} eps` : "Anime ongoing"); + parts.push(franchiseLabel ?? (eps ? `${eps} eps` : "Anime ongoing")); } if (manga || badge === "manga-only") { parts.push(manga?.chapters ? `${manga.chapters} ch` : "Manga ongoing"); diff --git a/src/types/index.ts b/src/types/index.ts index ccdc1be..3b64cd8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -99,6 +99,22 @@ export type MangaDexTitle = { value: string; }; +export type FranchiseSeason = { + id: number; + title: string; + romajiTitle: string; + format: string | null; + episodes: number | null; + year: number | null; +}; + +export type AnimeFranchise = { + rootId: number; + seasons: FranchiseSeason[]; + totalTvEpisodes: number; + tvSeasonCount: number; +}; + export type MangaDexInfo = { id: string; primaryTitle: string;