init: expo scaffold + api client + auth
This commit is contained in:
commit
3c407248a7
21 changed files with 8008 additions and 0 deletions
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue