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