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

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)
}