init: expo scaffold + api client + auth

This commit is contained in:
ImSavsis 2026-07-19 06:00:45 +05:00
commit 3c407248a7
21 changed files with 8008 additions and 0 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# anisav-mobile
anilibria не на ios, так что сделал сам. expo + react native.
каталог, поиск, расписание, плеер с автопропуском опенинга/эндинга, локальный вишлист без аккаунта, вход в аккаунт если надо синк списков.
бекенд тут не при чем, дергает `api.savsis.xyz`.
## запуск
```
npm install
npx expo start
```
сделано im.savsis.xyz

50
app.json Normal file
View file

@ -0,0 +1,50 @@
{
"expo": {
"name": "AniSav",
"slug": "anisav-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "anisav",
"userInterfaceStyle": "dark",
"backgroundColor": "#121212",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "xyz.savsis.anisav"
},
"android": {
"package": "xyz.savsis.anisav",
"adaptiveIcon": {
"backgroundColor": "#121212",
"foregroundImage": "./assets/adaptive-icon-foreground.png",
"backgroundImage": "./assets/adaptive-icon-background.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro",
"output": "static"
},
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#121212"
},
"plugins": [
"expo-router",
"expo-status-bar",
"expo-secure-store",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#121212"
}
],
"expo-video",
"expo-font"
]
}
}

56
app/(tabs)/_layout.tsx Normal file
View file

@ -0,0 +1,56 @@
import { Tabs } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { colors } from '../../src/lib/theme'
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: 'rgba(255,255,255,0.4)',
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
},
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Главная',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="catalog"
options={{
title: 'Каталог',
tabBarIcon: ({ color, size }) => <Ionicons name="grid" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="genres"
options={{
title: 'Жанры',
tabBarIcon: ({ color, size }) => <Ionicons name="pricetags" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="schedule"
options={{
title: 'Расписание',
tabBarIcon: ({ color, size }) => <Ionicons name="calendar" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Профиль',
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
}}
/>
</Tabs>
)
}

23
app/_layout.tsx Normal file
View file

@ -0,0 +1,23 @@
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { AuthProvider } from '../src/lib/AuthContext'
import { colors } from '../src/lib/theme'
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaProvider>
<AuthProvider>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="title/[id]" options={{ presentation: 'card' }} />
<Stack.Screen name="login" options={{ presentation: 'modal' }} />
</Stack>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

7249
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "anisav-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.7",
"expo-constants": "~57.0.6",
"expo-font": "~57.0.1",
"expo-haptics": "~57.0.1",
"expo-linking": "~57.0.3",
"expo-router": "~57.0.7",
"expo-secure-store": "~57.0.1",
"expo-splash-screen": "~57.0.4",
"expo-status-bar": "~57.0.1",
"expo-video": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}

37
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,37 @@
import { Text, TouchableOpacity, View, StyleSheet, Linking } from 'react-native'
import { colors } from '../lib/theme'
export default function Footer() {
return (
<View style={styles.row}>
<Text style={styles.text}>made by </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://im.savsis.xyz')}>
<Text style={styles.link}>im.savsis.xyz</Text>
</TouchableOpacity>
<Text style={styles.text}> with </Text>
<Text style={styles.heart}>♥</Text>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
text: {
color: colors.textFaint,
fontSize: 12,
},
link: {
color: colors.textDim,
fontSize: 12,
fontWeight: '600',
},
heart: {
color: colors.accent,
fontSize: 12,
},
})

71
src/lib/AuthContext.tsx Normal file
View file

@ -0,0 +1,71 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
import { api } from './api'
import { getToken, setToken, clearToken } from './auth'
import type { UserProfile } from './types'
interface AuthState {
user: UserProfile | null
loading: boolean
error: string | null
login: (login: string, password: string) => Promise<void>
logout: () => Promise<void>
clearError: () => void
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getToken()
.then(async (token) => {
if (!token) return
try {
const profile = await api.profile()
setUser(profile)
} catch {
await clearToken()
}
})
.finally(() => setLoading(false))
}, [])
async function login(loginValue: string, password: string) {
setError(null)
try {
const res = await api.login(loginValue, password)
if (!res.token) throw new Error(res.error || 'Неверный логин или пароль')
await setToken(res.token)
const profile = await api.profile()
setUser(profile)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка входа')
throw e
}
}
async function logout() {
try {
await api.logout()
} catch {
// best-effort
}
await clearToken()
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError: () => setError(null) }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

130
src/lib/api.ts Normal file
View file

@ -0,0 +1,130 @@
import type {
CatalogFilters,
CatalogResponse,
CollectionType,
Genre,
HistoryItem,
Release,
ScheduleDay,
ScheduleNow,
TimecodeItem,
UserProfile,
ValueDescription,
} from './types'
import { getToken } from './auth'
const GATEWAY = 'https://api.savsis.xyz/api/anilibria'
const API_BASE = `${GATEWAY}/api/v1`
export function imageUrl(path?: string | null): string | undefined {
if (!path) return undefined
if (path.startsWith('http')) return path
return `${GATEWAY}${path}`
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, init)
const data = await res.json().catch(() => null)
if (!res.ok) throw new Error(data?.error || `AniLiberty API ${res.status}: ${path}`)
return data as T
}
async function authReq<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await getToken()
if (!token) throw new Error('Не авторизован')
return req<T>(path, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
})
}
export const api = {
latest: (limit = 14) => req<Release[]>(`/anime/releases/latest?limit=${limit}`),
random: (limit = 5) => req<Release[]>(`/anime/releases/random?limit=${limit}`),
recommended: (limit = 10, releaseId?: number) =>
req<Release[]>(
`/anime/releases/recommended?limit=${limit}${releaseId ? `&release_id=${releaseId}` : ''}`
),
scheduleWeek: () => req<ScheduleDay[]>('/anime/schedule/week'),
scheduleNow: () => req<ScheduleNow>('/anime/schedule/now'),
genres: () => req<Genre[]>('/anime/genres'),
genreReleases: (genreId: number, page = 1, limit = 24) =>
req<CatalogResponse>(`/anime/genres/${genreId}/releases?page=${page}&limit=${limit}`),
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
search: (query: string) =>
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),
catalog: (filters: CatalogFilters, page = 1, limit = 24) =>
req<CatalogResponse>('/anime/catalog/releases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page, limit, f: filters }),
}),
refGenres: () => req<Genre[]>('/anime/catalog/references/genres'),
refTypes: () => req<ValueDescription[]>('/anime/catalog/references/types'),
refYears: () => req<number[]>('/anime/catalog/references/years'),
refSorting: () =>
req<Array<ValueDescription & { label: string }>>('/anime/catalog/references/sorting'),
refAgeRatings: () =>
req<Array<ValueDescription & { label: string }>>('/anime/catalog/references/age-ratings'),
refSeasons: () => req<ValueDescription[]>('/anime/catalog/references/seasons'),
login: (login: string, password: string) =>
req<{ token: string; error?: string }>('/accounts/users/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, password }),
}),
logout: () => authReq('/accounts/users/auth/logout', { method: 'POST' }),
profile: () => authReq<UserProfile>('/accounts/users/me/profile'),
favoritesList: (page = 1, limit = 48) =>
authReq<CatalogResponse>(`/accounts/users/me/favorites/releases?page=${page}&limit=${limit}`, {
method: 'POST',
body: JSON.stringify({ page, limit }),
}),
favoriteAdd: (releaseId: number) =>
authReq('/accounts/users/me/favorites', {
method: 'POST',
body: JSON.stringify([{ release_id: releaseId }]),
}),
favoriteRemove: (releaseId: number) =>
authReq('/accounts/users/me/favorites', {
method: 'DELETE',
body: JSON.stringify([{ release_id: releaseId }]),
}),
favoriteIds: () => authReq<unknown>('/accounts/users/me/favorites/ids'),
collectionList: (type: CollectionType, page = 1, limit = 48) =>
authReq<CatalogResponse>(
`/accounts/users/me/collections/releases?type_of_collection=${type}&page=${page}&limit=${limit}`
),
collectionAdd: (releaseId: number, type: CollectionType) =>
authReq('/accounts/users/me/collections', {
method: 'POST',
body: JSON.stringify([{ release_id: releaseId, type_of_collection: type }]),
}),
collectionRemove: (releaseId: number) =>
authReq('/accounts/users/me/collections', {
method: 'DELETE',
body: JSON.stringify([{ release_id: releaseId }]),
}),
collectionIds: () => authReq<unknown>('/accounts/users/me/collections/ids'),
historyList: () => authReq<{ data: HistoryItem[] }>('/accounts/users/me/views/history'),
timecodes: () => authReq<TimecodeItem[]>('/accounts/users/me/views/timecodes'),
timecodeSave: (releaseEpisodeId: string, time: number, isWatched: boolean) =>
authReq('/accounts/users/me/views/timecodes', {
method: 'POST',
body: JSON.stringify([{ release_episode_id: releaseEpisodeId, time, is_watched: isWatched }]),
}),
}

15
src/lib/auth.ts Normal file
View file

@ -0,0 +1,15 @@
import * as SecureStore from 'expo-secure-store'
const KEY = 'anisav_token'
export async function getToken(): Promise<string | null> {
return SecureStore.getItemAsync(KEY)
}
export async function setToken(token: string): Promise<void> {
await SecureStore.setItemAsync(KEY, token)
}
export async function clearToken(): Promise<void> {
await SecureStore.deleteItemAsync(KEY)
}

39
src/lib/localProgress.ts Normal file
View file

@ -0,0 +1,39 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const KEY = 'anisav:progress'
interface ProgressEntry {
time: number
is_watched: boolean
}
async function readAll(): Promise<Record<string, ProgressEntry>> {
try {
const raw = await AsyncStorage.getItem(KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
async function writeAll(data: Record<string, ProgressEntry>): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(data))
}
export async function getLocalProgress(episodeId: string): Promise<ProgressEntry | undefined> {
return (await readAll())[episodeId]
}
export async function getAllLocalProgress(): Promise<Record<string, ProgressEntry>> {
return readAll()
}
export async function saveLocalProgress(
episodeId: string,
time: number,
isWatched: boolean
): Promise<void> {
const all = await readAll()
all[episodeId] = { time, is_watched: isWatched }
await writeAll(all)
}

22
src/lib/theme.ts Normal file
View file

@ -0,0 +1,22 @@
export const colors = {
background: '#121212',
surface: '#181818',
surfaceCard: '#1e1e1e',
surfaceRaised: '#232323',
accent: '#fe3635',
accentHover: '#ff5352',
text: '#ffffff',
textDim: 'rgba(255,255,255,0.65)',
textFaint: 'rgba(255,255,255,0.4)',
border: 'rgba(255,255,255,0.08)',
overlay: 'rgba(0,0,0,0.7)',
}
export const radius = {
sm: 6,
md: 10,
lg: 16,
full: 999,
}
export const spacing = (n: number) => n * 4

167
src/lib/types.ts Normal file
View file

@ -0,0 +1,167 @@
export interface ImageObj {
src?: string
preview?: string
thumbnail?: string
optimized?: {
src?: string
preview?: string
thumbnail?: string
}
}
export interface ValueDescription {
value: string
description: string
}
export interface Genre {
id: number
name: string
image?: ImageObj
total_releases?: number
}
export interface AgeRating {
value: string
label: string
is_adult?: boolean
description: string
}
export interface EpisodeSkip {
start: number
stop: number
}
export interface Episode {
id: string
name: string | null
name_english?: string | null
ordinal: number
opening?: EpisodeSkip | null
ending?: EpisodeSkip | null
preview?: ImageObj
hls_480?: string | null
hls_720?: string | null
hls_1080?: string | null
duration?: number | null
rutube_id?: string | null
youtube_id?: string | null
sort_order?: number
release_id: number
}
export interface Torrent {
id: number
hash: string
size: number
type?: ValueDescription
color?: { value: string; description: string }
codec?: { value: string; label: string; description: string }
label: string
quality?: { value: string; description: string }
magnet: string
filename: string
seeders: number
leechers: number
bitrate?: number
is_hardsub?: boolean
description?: string
created_at?: string
updated_at?: string
completed_times?: number
}
export interface Release {
id: number
type: ValueDescription
year: number
name: { main: string; english?: string | null; alternative?: string | null }
alias: string
season: ValueDescription
poster: ImageObj
fresh_at?: string
created_at?: string
updated_at?: string
is_ongoing: boolean
age_rating: AgeRating
publish_day?: { value: number; description: string }
description?: string | null
notification?: string | null
episodes_total: number | null
is_in_production: boolean
is_blocked_by_geo: boolean
is_blocked_by_copyrights: boolean
added_in_users_favorites?: number
average_duration_of_episode?: number
genres?: Genre[]
episodes?: Episode[]
torrents?: Torrent[]
latest_episode?: Episode
}
export interface ScheduleDay {
release: Release
full_season_is_released: boolean
published_release_episode?: Episode | null
next_release_episode_number?: number | null
}
export interface ScheduleNow {
today: ScheduleDay[]
tomorrow: ScheduleDay[]
yesterday: ScheduleDay[]
}
export interface Pagination {
total: number
count: number
per_page: number
current_page: number
total_pages: number
}
export interface CatalogResponse {
data: Release[]
meta: { pagination: Pagination }
}
export interface UserProfile {
id: number
login: string
email?: string
nickname: string
avatar?: ImageObj
is_banned?: boolean
created_at?: string
}
export type CollectionType = 'WATCHING' | 'PLANNED' | 'WATCHED' | 'POSTPONED' | 'ABANDONED'
export interface TimecodeItem {
release_episode_id: string
time: number
is_watched: boolean
}
export interface HistoryItem {
id: number
time: number
is_watched: boolean
updated_at: string
release_episode_id: string
release_episode: Episode
release: Release
}
export interface CatalogFilters {
genres?: number[]
types?: string[]
seasons?: string[]
years?: { from_year?: number; to_year?: number }
search?: string
sorting?: string
age_ratings?: string[]
publish_statuses?: string[]
production_statuses?: string[]
}

51
src/lib/wishlist.ts Normal file
View file

@ -0,0 +1,51 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { Release } from './types'
const KEY = 'anisav:wishlist'
type Listener = () => void
const listeners = new Set<Listener>()
async function read(): Promise<Release[]> {
try {
const raw = await AsyncStorage.getItem(KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
async function write(items: Release[]): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(items))
listeners.forEach((l) => l())
}
export function getWishlist(): Promise<Release[]> {
return read()
}
export async function isInWishlist(releaseId: number): Promise<boolean> {
return (await read()).some((r) => r.id === releaseId)
}
export async function addToWishlist(release: Release): Promise<void> {
const items = await read()
if (items.some((r) => r.id === release.id)) return
await write([release, ...items])
}
export async function removeFromWishlist(releaseId: number): Promise<void> {
await write((await read()).filter((r) => r.id !== releaseId))
}
export async function toggleWishlist(release: Release): Promise<boolean> {
const inList = await isInWishlist(release.id)
if (inList) await removeFromWishlist(release.id)
else await addToWishlist(release)
return !inList
}
export function onWishlistChange(cb: Listener): () => void {
listeners.add(cb)
return () => listeners.delete(cb)
}

6
tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}