init: expo scaffold + api client + auth
This commit is contained in:
commit
3c407248a7
21 changed files with 8008 additions and 0 deletions
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
16
README.md
Normal 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
50
app.json
Normal 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
56
app/(tabs)/_layout.tsx
Normal 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
23
app/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
assets/adaptive-icon-background.png
Normal file
BIN
assets/adaptive-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/adaptive-icon-foreground.png
Normal file
BIN
assets/adaptive-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
7249
package-lock.json
generated
Normal file
7249
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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
37
src/components/Footer.tsx
Normal 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
71
src/lib/AuthContext.tsx
Normal 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
130
src/lib/api.ts
Normal 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
15
src/lib/auth.ts
Normal 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
39
src/lib/localProgress.ts
Normal 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
22
src/lib/theme.ts
Normal 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
167
src/lib/types.ts
Normal 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
51
src/lib/wishlist.ts
Normal 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
6
tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue