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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
44 changes: 27 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ Drill into a single arc to see per-episode ↔ per-chapter alignment:
- **Expo Router** for file-based universal routes
- **TanStack Query** + **Zustand**
- **AniList GraphQL** for anime/manga metadata
- Bundled JSON in `src/data/mappings/` for episode↔chapter alignments
- **Supabase** (Postgres + RLS) for episode↔chapter mappings, search aliases,
genre filters, and synced user progress/preferences
- **Tauri 2** for desktop binaries

## Getting started
Expand Down Expand Up @@ -80,24 +81,31 @@ Desktop is shipped via Tauri 2 wrapping the Expo web export. One-time setup:

## Contributing a mapping

Mappings live in Supabase — the `series`, `arc_mappings`, and `movies` tables
(see `supabase/migrations/`) — not in the repo, so a new or corrected mapping
reaches every client on next launch with no app release. The catalog tables are
read-only under RLS; write via the Supabase dashboard, SQL editor, or a
service-role script.

1. Find the AniList anime ID and manga ID for the series.
2. Add a file at `src/data/mappings/<slug>.json` following the schema in `src/types/index.ts` → `SeriesMapping`.
3. Import it in `src/data/index.ts`.

The mapping schema:

```json
{
"anilistAnimeId": 21,
"anilistMangaId": 30013,
"title": "One Piece",
"mappings": [
{ "episodes": [1, 3], "chapters": [1, 7], "arc": "Romance Dawn" }
]
}
2. Insert a `series` row and its arcs:

```sql
with s as (
insert into series (anilist_anime_id, anilist_manga_id, title)
values (21, 30013, 'One Piece')
returning id
)
insert into arc_mappings
(series_id, position, episode_start, episode_end, chapter_start, chapter_end, arc)
select s.id, v.*
from s, (values
(0, 1, 3, 1, 7, 'Romance Dawn')
) as v(position, episode_start, episode_end, chapter_start, chapter_end, arc);
```

Episode and chapter ranges are inclusive.
Episode and chapter ranges are inclusive; omit `episode_start`/`episode_end`
for manga-only arcs.

## Project layout

Expand All @@ -109,8 +117,10 @@ app/ # Expo Router routes
src/
api/anilist.ts # GraphQL client
components/ # SeriesCard, EpisodeChapterRail
data/ # bundled mappings + lookup helpers
data/ # Supabase catalog fetch + mapping helpers
state/ # zustand stores + cloud sync
types/ # shared TS types
supabase/migrations/ # database schema
docs/superpowers/specs/ # design docs
src-tauri/ # desktop wrapper (added after web bundle works)
```
Expand Down
50 changes: 47 additions & 3 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { useEffect } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Stack, useRouter, usePathname } from "expo-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import * as SplashScreen from "expo-splash-screen";
import {
CATALOG_QUERY_KEY,
useCatalogQuery,
useHydrateSearchAliases,
} from "@/data/catalog";
import {
useFonts,
SpaceGrotesk_400Regular,
Expand All @@ -15,17 +26,38 @@ import {
import { ZenTokyoZoo_400Regular } from "@expo-google-fonts/zen-tokyo-zoo";
import { usePreferences } from "@/state/preferences";
import { useAuthEmail } from "@/state/auth";
import { startCloudSync } from "@/state/sync";
import type { PressableState } from "@/types";
import { FONT } from "@/theme";

SplashScreen.preventAutoHideAsync().catch(() => {});

// Reconcile local progress/preferences with Supabase once a session exists.
startCloudSync();

const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 5 * 60 * 1000, retry: 1 },
},
});

// Persist only the catalog query to AsyncStorage so a cold (or offline) launch
// renders the anime<->manga mappings instantly, then refreshes in the
// background. AniList/MangaDex results stay in-memory only.
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "kasane-query-cache",
});
const CATALOG_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;

// Warms the catalog at launch and keeps the search-alias table hydrated so
// everything is ready before the first screen needs a mapping.
function CatalogWarmup() {
useCatalogQuery();
useHydrateSearchAliases();
return null;
}

function GlobalHeader() {
const router = useRouter();
const pathname = usePathname();
Expand Down Expand Up @@ -105,10 +137,22 @@ export default function RootLayout() {
if (!fontsLoaded) return null;

return (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: CATALOG_CACHE_MAX_AGE,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) &&
query.queryKey[0] === CATALOG_QUERY_KEY[0],
},
}}
>
<SafeAreaProvider>
<StatusBar style="light" />
<View style={headerStyles.root}>
<CatalogWarmup />
<GlobalHeader />
<Stack
screenOptions={{
Expand All @@ -126,7 +170,7 @@ export default function RootLayout() {
</Stack>
</View>
</SafeAreaProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
);
}

Expand Down
16 changes: 12 additions & 4 deletions app/anime/[id]/arc/[arcIdx].tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useMemo } from "react";
import { Text, View, StyleSheet } from "react-native";
import { ActivityIndicator, Text, View, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { findMappingByMediaId } from "@/data";
import { useCatalog } from "@/data/catalog";
import { ArcDetailView } from "@/components/ArcDetailView";
import { FONT } from "@/theme";

Expand All @@ -10,7 +9,16 @@ export default function AnimeArcDetail() {
const mediaId = Number(id);
const arcIndex = Number(arcIdx);

const mapping = useMemo(() => findMappingByMediaId(mediaId), [mediaId]);
const { findMapping, isLoaded } = useCatalog();
const mapping = findMapping(mediaId);

if (!isLoaded) {
return (
<View style={styles.center}>
<ActivityIndicator color="#7c5cff" />
</View>
);
}

if (!mapping) {
return (
Expand Down
32 changes: 13 additions & 19 deletions app/anime/[id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import {
buildSyntheticMapping,
chapterToEpisodes,
episodeToChapters,
findMappingByMediaId,
} from "@/data";
import { useCatalog } from "@/data/catalog";
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 type { AnimeFranchise, SeriesMapping } from "@/types";
import { FONT } from "@/theme";

export default function AnimeDetail() {
Expand All @@ -45,13 +45,14 @@ export default function AnimeDetail() {
staleTime: 24 * 60 * 60 * 1000,
});

const curatedMapping = useMemo(
() => findMappingByMediaId(mediaId),
[mediaId],
);
const { findMapping, isLoaded: catalogLoaded } = useCatalog();
const curatedMapping = findMapping(mediaId);
const syntheticMapping = useMemo(
() => (media && !curatedMapping ? buildSyntheticMapping(media) : null),
[media, curatedMapping],
() =>
media && catalogLoaded && !curatedMapping
? buildSyntheticMapping(media)
: null,
[media, catalogLoaded, curatedMapping],
);
const mapping = curatedMapping ?? syntheticMapping;
const isAutoEstimated = !curatedMapping && !!syntheticMapping;
Expand Down Expand Up @@ -154,9 +155,8 @@ export default function AnimeDetail() {
<Paragraph style={styles.autoBannerBody}>
Linear pacing — anime episode count distributed evenly across
the manga chapter count. Real arcs rarely adapt at a uniform
rate, so treat numbers as a rough guide. Curated JSON in{" "}
<Text style={styles.code}>src/data/mappings/</Text> overrides
this.
rate, so treat numbers as a rough guide. A curated mapping
overrides this estimate.
</Paragraph>
</View>
)}
Expand All @@ -172,8 +172,7 @@ export default function AnimeDetail() {
<Text style={styles.noMappingTitle}>No mapping available yet</Text>
<Paragraph style={styles.noMappingBody}>
We couldn&apos;t find an anime↔manga adaptation pair on AniList for
this entry, and no curated mapping exists. Add a JSON file to{" "}
<Text style={styles.code}>src/data/mappings/</Text> in the repo.
this entry, and no curated mapping exists for it yet.
</Paragraph>
</View>
)}
Expand Down Expand Up @@ -228,11 +227,7 @@ function SeasonsList({
);
}

function QuickLookup({
mapping,
}: {
mapping: ReturnType<typeof findMappingByMediaId>;
}) {
function QuickLookup({ mapping }: { mapping: SeriesMapping | null }) {
const [epInput, setEpInput] = useState("");
const [chInput, setChInput] = useState("");

Expand Down Expand Up @@ -356,7 +351,6 @@ const styles = StyleSheet.create({
lineHeight: 18,
fontFamily: FONT.regular,
},
code: { fontFamily: "Menlo", color: "#7c5cff" },
lookup: { gap: 12, paddingTop: 8 },
lookupRow: {
flexDirection: "row",
Expand Down
16 changes: 12 additions & 4 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { Link } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { getLatestAnime, searchMedia } from "@/api/anilist";
import { pairResults } from "@/data";
import { GENRE_FILTERS, splitHiddenForAniList } from "@/data/genreFilters";
import { useGenreFilters } from "@/data/catalog";
import { splitHiddenForAniList, type GenreFilter } from "@/data/genreFilters";
import { SeriesCard } from "@/components/SeriesCard";
import { ContinueSection } from "@/components/ContinueSection";
import {
Expand Down Expand Up @@ -48,7 +49,11 @@ export default function HomeScreen() {
const [filtersOpen, setFiltersOpen] = useState(false);
const hiddenGenres = usePreferences((s) => s.hiddenGenres);
const toggleHiddenGenre = usePreferences((s) => s.toggleHiddenGenre);
const { genreNotIn, tagNotIn } = splitHiddenForAniList(hiddenGenres);
const genreFilters = useGenreFilters();
const { genreNotIn, tagNotIn } = splitHiddenForAniList(
hiddenGenres,
genreFilters,
);
const { width: windowWidth } = useWindowDimensions();
const isMobile = windowWidth < MOBILE_WIDTH_BREAKPOINT;
const hiddenCount = hiddenGenres.length;
Expand Down Expand Up @@ -117,7 +122,7 @@ export default function HomeScreen() {

{!isMobile && filtersOpen && (
<View style={styles.genreFilters}>
{GENRE_FILTERS.map((f) => {
{genreFilters.map((f) => {
const included = !hiddenGenres.includes(f.id);
return (
<Pressable
Expand All @@ -142,6 +147,7 @@ export default function HomeScreen() {
{isMobile && (
<GenreFilterSheet
visible={filtersOpen}
filters={genreFilters}
hiddenGenres={hiddenGenres}
onToggle={toggleHiddenGenre}
onClose={() => setFiltersOpen(false)}
Expand Down Expand Up @@ -182,11 +188,13 @@ export default function HomeScreen() {

function GenreFilterSheet({
visible,
filters,
hiddenGenres,
onToggle,
onClose,
}: {
visible: boolean;
filters: readonly GenreFilter[];
hiddenGenres: string[];
onToggle: (id: string) => void;
onClose: () => void;
Expand Down Expand Up @@ -219,7 +227,7 @@ function GenreFilterSheet({
style={styles.sheetScroll}
contentContainerStyle={styles.sheetScrollContent}
>
{GENRE_FILTERS.map((f) => {
{filters.map((f) => {
const included = !hiddenGenres.includes(f.id);
return (
<Pressable
Expand Down
16 changes: 12 additions & 4 deletions app/manga/[id]/arc/[arcIdx].tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useMemo } from "react";
import { Text, View, StyleSheet } from "react-native";
import { ActivityIndicator, Text, View, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { findMappingByMediaId } from "@/data";
import { useCatalog } from "@/data/catalog";
import { ArcDetailView } from "@/components/ArcDetailView";
import { FONT } from "@/theme";

Expand All @@ -10,7 +9,16 @@ export default function MangaArcDetail() {
const mediaId = Number(id);
const arcIndex = Number(arcIdx);

const mapping = useMemo(() => findMappingByMediaId(mediaId), [mediaId]);
const { findMapping, isLoaded } = useCatalog();
const mapping = findMapping(mediaId);

if (!isLoaded) {
return (
<View style={styles.center}>
<ActivityIndicator color="#7c5cff" />
</View>
);
}

if (!mapping) {
return (
Expand Down
Loading
Loading