ci: typecheck on push

This commit is contained in:
ImSavsis 2026-07-19 06:05:12 +05:00
parent 3c407248a7
commit 144a2cd227
17 changed files with 2345 additions and 1 deletions

18
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: ci
on:
push:
branches: [master]
pull_request:
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

525
app/(tabs)/catalog.tsx Normal file
View file

@ -0,0 +1,525 @@
import { useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
Dimensions,
FlatList,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { api } from '../../src/lib/api'
import type { CatalogFilters, Genre, Release, ValueDescription } from '../../src/lib/types'
import { colors, radius, spacing } from '../../src/lib/theme'
import AnimeCard from '../../src/components/AnimeCard'
import Loader from '../../src/components/Loader'
import Chip from '../../src/components/Chip'
type SortOpt = ValueDescription & { label: string }
const LIMIT = 24
const DEFAULT_SORTING = 'FRESH_AT_DESC'
function getColumns() {
const width = Dimensions.get('window').width
return Math.max(2, Math.floor(width / 180))
}
export default function CatalogScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const [columns, setColumns] = useState(getColumns)
useEffect(() => {
const sub = Dimensions.addEventListener('change', () => setColumns(getColumns()))
return () => sub.remove()
}, [])
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query.trim()), 400)
return () => clearTimeout(t)
}, [query])
const isSearching = debouncedQuery.length > 0
const [refGenres, setRefGenres] = useState<Genre[]>([])
const [refTypes, setRefTypes] = useState<ValueDescription[]>([])
const [refSeasons, setRefSeasons] = useState<ValueDescription[]>([])
const [refSorting, setRefSorting] = useState<SortOpt[]>([])
useEffect(() => {
api.refGenres().then(setRefGenres).catch(() => {})
api.refTypes().then(setRefTypes).catch(() => {})
api.refSeasons().then(setRefSeasons).catch(() => {})
api.refSorting().then(setRefSorting).catch(() => {})
}, [])
const [sorting, setSorting] = useState(DEFAULT_SORTING)
const [selectedGenres, setSelectedGenres] = useState<number[]>([])
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
const [selectedSeasons, setSelectedSeasons] = useState<string[]>([])
const [filtersVisible, setFiltersVisible] = useState(false)
const [draftSorting, setDraftSorting] = useState(DEFAULT_SORTING)
const [draftGenres, setDraftGenres] = useState<number[]>([])
const [draftTypes, setDraftTypes] = useState<string[]>([])
const [draftSeasons, setDraftSeasons] = useState<string[]>([])
function openFilters() {
setDraftSorting(sorting)
setDraftGenres(selectedGenres)
setDraftTypes(selectedTypes)
setDraftSeasons(selectedSeasons)
setFiltersVisible(true)
}
function applyFilters() {
setSorting(draftSorting)
setSelectedGenres(draftGenres)
setSelectedTypes(draftTypes)
setSelectedSeasons(draftSeasons)
setFiltersVisible(false)
}
function resetFilters() {
setDraftSorting(DEFAULT_SORTING)
setDraftGenres([])
setDraftTypes([])
setDraftSeasons([])
setSorting(DEFAULT_SORTING)
setSelectedGenres([])
setSelectedTypes([])
setSelectedSeasons([])
setFiltersVisible(false)
}
function toggleDraftGenre(id: number) {
setDraftGenres((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]))
}
function toggleDraftType(value: string) {
setDraftTypes((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]))
}
function toggleDraftSeason(value: string) {
setDraftSeasons((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]))
}
const activeFilterCount = selectedGenres.length + selectedTypes.length + selectedSeasons.length
const filters: CatalogFilters = useMemo(
() => ({
genres: selectedGenres.length ? selectedGenres : undefined,
types: selectedTypes.length ? selectedTypes : undefined,
seasons: selectedSeasons.length ? selectedSeasons : undefined,
sorting,
}),
[selectedGenres, selectedTypes, selectedSeasons, sorting]
)
const [results, setResults] = useState<Release[] | null>(null)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
let cancelled = false
setLoading(true)
setResults(null)
setPage(1)
if (isSearching) {
api
.search(debouncedQuery)
.then((data) => {
if (cancelled) return
setResults(data)
setTotalPages(1)
})
.catch(() => {
if (!cancelled) setResults([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
} else {
api
.catalog(filters, 1, LIMIT)
.then((res) => {
if (cancelled) return
setResults(res.data)
setTotalPages(res.meta.pagination.total_pages)
})
.catch(() => {
if (!cancelled) setResults([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
}
return () => {
cancelled = true
}
}, [filters, isSearching, debouncedQuery])
function loadMore() {
if (isSearching || loading || loadingMore || page >= totalPages) return
const nextPage = page + 1
setLoadingMore(true)
api
.catalog(filters, nextPage, LIMIT)
.then((res) => {
setResults((prev) => (prev ? [...prev, ...res.data] : res.data))
setPage(nextPage)
setTotalPages(res.meta.pagination.total_pages)
})
.catch(() => {})
.finally(() => setLoadingMore(false))
}
const paddedResults = useMemo(() => {
if (!results) return []
const remainder = results.length % columns
if (remainder === 0) return results
return [...results, ...Array(columns - remainder).fill(null)]
}, [results, columns])
return (
<View style={[styles.root, { paddingTop: insets.top }]}>
<Text style={styles.headerTitle}>{isSearching ? `Поиск: "${debouncedQuery}"` : 'Каталог'}</Text>
<View style={styles.searchRow}>
<View style={styles.searchBox}>
<Ionicons name="search" size={16} color={colors.textFaint} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Поиск аниме..."
placeholderTextColor={colors.textFaint}
style={styles.searchInput}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
/>
{query.length > 0 && (
<Pressable hitSlop={8} onPress={() => setQuery('')}>
<Ionicons name="close-circle" size={16} color={colors.textFaint} />
</Pressable>
)}
</View>
{!isSearching && (
<Pressable style={styles.filterButton} onPress={openFilters}>
<Ionicons name="options" size={18} color={colors.text} />
<Text style={styles.filterButtonText}>Фильтры</Text>
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
</View>
)}
</Pressable>
)}
</View>
{loading ? (
<Loader label="Загрузка..." />
) : results && results.length === 0 ? (
<View style={styles.emptyWrap}>
<Ionicons name="file-tray-outline" size={40} color={colors.textFaint} />
<Text style={styles.emptyText}>Ничего не найдено</Text>
</View>
) : (
<FlatList
key={`grid-${columns}`}
data={paddedResults}
keyExtractor={(item, index) => (item ? String(item.id) : `filler-${index}`)}
numColumns={columns}
columnWrapperStyle={styles.row}
contentContainerStyle={[styles.listContent, { paddingBottom: insets.bottom + spacing(6) }]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onEndReachedThreshold={0.4}
onEndReached={loadMore}
renderItem={({ item }) => (
<View style={styles.cardCell}>
{item && (
<AnimeCard release={item} onPress={() => router.push(`/title/${item.alias || item.id}`)} />
)}
</View>
)}
ListFooterComponent={
loadingMore ? (
<View style={styles.footerLoader}>
<ActivityIndicator color={colors.accent} />
</View>
) : (
<View style={{ height: spacing(4) }} />
)
}
/>
)}
<Modal
visible={filtersVisible}
transparent
animationType="slide"
onRequestClose={() => setFiltersVisible(false)}
>
<View style={styles.modalOverlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setFiltersVisible(false)} />
<View style={[styles.sheet, { paddingBottom: insets.bottom + spacing(4) }]}>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Фильтры</Text>
<Pressable hitSlop={8} onPress={() => setFiltersVisible(false)}>
<Ionicons name="close" size={22} color={colors.textDim} />
</Pressable>
</View>
<ScrollView style={styles.sheetBody} showsVerticalScrollIndicator={false}>
<FilterSection title="Сортировка">
<View style={styles.chipsWrap}>
{refSorting.map((s) => (
<Pressable key={s.value} onPress={() => setDraftSorting(s.value)}>
<Chip accent={draftSorting === s.value}>{s.label}</Chip>
</Pressable>
))}
</View>
</FilterSection>
<FilterSection title="Тип">
<View style={styles.chipsWrap}>
{refTypes.map((t) => (
<Pressable key={t.value} onPress={() => toggleDraftType(t.value)}>
<Chip accent={draftTypes.includes(t.value)}>{t.description}</Chip>
</Pressable>
))}
</View>
</FilterSection>
<FilterSection title="Сезон">
<View style={styles.chipsWrap}>
{refSeasons.map((s) => (
<Pressable key={s.value} onPress={() => toggleDraftSeason(s.value)}>
<Chip accent={draftSeasons.includes(s.value)}>{s.description}</Chip>
</Pressable>
))}
</View>
</FilterSection>
<FilterSection title="Жанры">
<View style={styles.chipsWrap}>
{refGenres.map((g) => (
<Pressable key={g.id} onPress={() => toggleDraftGenre(g.id)}>
<Chip accent={draftGenres.includes(g.id)}>{g.name}</Chip>
</Pressable>
))}
</View>
</FilterSection>
</ScrollView>
<View style={styles.sheetFooter}>
<Pressable style={styles.resetButton} onPress={resetFilters}>
<Text style={styles.resetButtonText}>Сбросить</Text>
</Pressable>
<Pressable style={styles.applyButton} onPress={applyFilters}>
<Text style={styles.applyButtonText}>Применить</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
)
}
function FilterSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>{title}</Text>
{children}
</View>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.background,
},
headerTitle: {
color: colors.text,
fontSize: 24,
fontWeight: '800',
paddingHorizontal: spacing(4),
paddingTop: spacing(3),
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(2),
paddingHorizontal: spacing(4),
paddingTop: spacing(3),
paddingBottom: spacing(2),
},
searchBox: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing(2),
backgroundColor: colors.surfaceRaised,
borderRadius: radius.full,
paddingHorizontal: spacing(3),
height: 40,
},
searchInput: {
flex: 1,
color: colors.text,
fontSize: 14,
padding: 0,
},
filterButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(1),
backgroundColor: colors.surfaceRaised,
borderRadius: radius.full,
paddingHorizontal: spacing(3),
height: 40,
},
filterButtonText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
filterBadge: {
backgroundColor: colors.accent,
borderRadius: radius.full,
minWidth: 18,
height: 18,
paddingHorizontal: spacing(1),
alignItems: 'center',
justifyContent: 'center',
},
filterBadgeText: {
color: colors.text,
fontSize: 10,
fontWeight: '700',
},
emptyWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing(3),
paddingBottom: spacing(24),
},
emptyText: {
color: colors.textFaint,
fontSize: 14,
},
listContent: {
paddingHorizontal: spacing(4),
paddingTop: spacing(2),
gap: spacing(4),
},
row: {
gap: spacing(3),
},
cardCell: {
flex: 1,
},
footerLoader: {
paddingVertical: spacing(6),
alignItems: 'center',
},
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: colors.overlay,
},
sheet: {
backgroundColor: colors.surface,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
maxHeight: '85%',
paddingHorizontal: spacing(4),
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: radius.full,
backgroundColor: colors.border,
alignSelf: 'center',
marginTop: spacing(2),
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing(3),
},
sheetTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
sheetBody: {
marginBottom: spacing(2),
},
filterSection: {
marginBottom: spacing(4),
},
filterSectionTitle: {
color: colors.textFaint,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
marginBottom: spacing(2),
},
chipsWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing(2),
},
sheetFooter: {
flexDirection: 'row',
gap: spacing(3),
paddingTop: spacing(3),
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
resetButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: 46,
borderRadius: radius.md,
backgroundColor: colors.surfaceRaised,
},
resetButtonText: {
color: colors.textDim,
fontSize: 14,
fontWeight: '700',
},
applyButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: 46,
borderRadius: radius.md,
backgroundColor: colors.accent,
},
applyButtonText: {
color: colors.text,
fontSize: 14,
fontWeight: '700',
},
})

184
app/(tabs)/genres.tsx Normal file
View file

@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Animated,
FlatList,
Image,
LayoutAnimation,
Platform,
Pressable,
RefreshControl,
StyleSheet,
Text,
UIManager,
View,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { api, imageUrl } from '../../src/lib/api'
import type { Genre } from '../../src/lib/types'
import { colors, radius, spacing } from '../../src/lib/theme'
import Loader from '../../src/components/Loader'
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true)
}
interface GenreTileProps {
genre: Genre
onPress: () => void
}
function GenreTile({ genre, onPress }: GenreTileProps) {
const scale = useRef(new Animated.Value(1)).current
const image = imageUrl(genre.image?.optimized?.preview || genre.image?.preview)
function pressIn() {
Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 50 }).start()
}
function pressOut() {
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 50 }).start()
}
return (
<Pressable onPress={onPress} onPressIn={pressIn} onPressOut={pressOut} style={styles.tileWrap}>
<Animated.View style={[styles.tile, { transform: [{ scale }] }]}>
{image && <Image source={{ uri: image }} style={styles.image} resizeMode="cover" />}
<View style={styles.overlay} />
<View style={styles.overlayBottom} />
<View style={styles.content}>
<Text numberOfLines={2} style={styles.name}>
{genre.name}
</Text>
{genre.total_releases != null && (
<Text style={styles.count}>{genre.total_releases} тайтлов</Text>
)}
</View>
</Animated.View>
</Pressable>
)
}
export default function GenresScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [genres, setGenres] = useState<Genre[] | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
try {
const data = await api.genres()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setGenres(data)
} catch {
setGenres([])
}
}, [])
useEffect(() => {
load()
}, [load])
async function onRefresh() {
setRefreshing(true)
await load()
setRefreshing(false)
}
if (!genres) return <Loader label="Загружаем жанры..." />
return (
<FlatList
style={styles.screen}
data={genres}
keyExtractor={(item) => String(item.id)}
numColumns={2}
columnWrapperStyle={styles.row}
contentContainerStyle={[
styles.list,
{ paddingTop: insets.top + spacing(4), paddingBottom: insets.bottom + spacing(8) },
]}
refreshControl={
<RefreshControl tintColor={colors.accent} refreshing={refreshing} onRefresh={onRefresh} />
}
ListHeaderComponent={<Text style={styles.title}>Жанры</Text>}
renderItem={({ item }) => (
<GenreTile
genre={item}
onPress={() =>
router.push({ pathname: '/(tabs)/catalog', params: { genre: String(item.id) } })
}
/>
)}
/>
)
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.background,
},
list: {
paddingHorizontal: spacing(4),
},
title: {
color: colors.text,
fontSize: 22,
fontWeight: '800',
marginBottom: spacing(4),
},
row: {
justifyContent: 'space-between',
},
tileWrap: {
width: '48%',
marginBottom: spacing(3),
},
tile: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: radius.lg,
overflow: 'hidden',
backgroundColor: colors.surfaceRaised,
},
image: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.55,
},
overlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.25)',
},
overlayBottom: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '65%',
backgroundColor: 'rgba(0,0,0,0.55)',
},
content: {
flex: 1,
justifyContent: 'flex-end',
padding: spacing(3),
},
name: {
color: colors.text,
fontSize: 14,
fontWeight: '700',
},
count: {
color: colors.textFaint,
fontSize: 11,
marginTop: spacing(0.5),
},
})

209
app/(tabs)/index.tsx Normal file
View file

@ -0,0 +1,209 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Animated, Image, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { api, imageUrl } from '../../src/lib/api'
import type { Release, ScheduleDay } from '../../src/lib/types'
import { colors, radius, spacing } from '../../src/lib/theme'
import Row from '../../src/components/Row'
import Loader from '../../src/components/Loader'
import Footer from '../../src/components/Footer'
export default function HomeScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [latest, setLatest] = useState<Release[] | null>(null)
const [recommended, setRecommended] = useState<Release[]>([])
const [today, setToday] = useState<ScheduleDay[]>([])
const [refreshing, setRefreshing] = useState(false)
const heroOpacity = useRef(new Animated.Value(0)).current
const heroTranslate = useRef(new Animated.Value(16)).current
const buttonScale = useRef(new Animated.Value(1)).current
const load = useCallback(async () => {
const [latestRes, recommendedRes, scheduleRes] = await Promise.all([
api.latest(21).catch(() => [] as Release[]),
api.recommended(14).catch(() => [] as Release[]),
api.scheduleNow().catch(() => null),
])
setLatest(latestRes)
setRecommended(recommendedRes)
setToday(scheduleRes?.today?.filter((s) => s.release) ?? [])
}, [])
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (!latest) return
heroOpacity.setValue(0)
heroTranslate.setValue(16)
Animated.parallel([
Animated.timing(heroOpacity, { toValue: 1, duration: 450, useNativeDriver: true }),
Animated.timing(heroTranslate, { toValue: 0, duration: 450, useNativeDriver: true }),
]).start()
}, [latest, heroOpacity, heroTranslate])
async function onRefresh() {
setRefreshing(true)
await load()
setRefreshing(false)
}
function pressButtonIn() {
Animated.spring(buttonScale, { toValue: 0.94, useNativeDriver: true, speed: 50 }).start()
}
function pressButtonOut() {
Animated.spring(buttonScale, { toValue: 1, useNativeDriver: true, speed: 50 }).start()
}
if (!latest) {
return <Loader label="Загрузка..." />
}
const hero = latest[0]
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingTop: insets.top, paddingBottom: spacing(4) }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
}
>
{hero && (
<Animated.View
style={[
styles.hero,
{ opacity: heroOpacity, transform: [{ translateY: heroTranslate }] },
]}
>
<Image
source={{ uri: imageUrl(hero.poster?.optimized?.preview || hero.poster?.preview) }}
style={styles.heroImage}
resizeMode="cover"
/>
<View style={styles.heroOverlay} />
<View style={styles.heroOverlayBottom} />
<View style={styles.heroContent}>
<View style={styles.heroBadge}>
<Text style={styles.heroBadgeText}>Новинка</Text>
</View>
<Text style={styles.heroTitle} numberOfLines={2}>
{hero.name.main}
</Text>
{!!hero.description && (
<Text style={styles.heroDescription} numberOfLines={2}>
{hero.description}
</Text>
)}
<Pressable
onPress={() => router.push(`/title/${hero.alias || hero.id}`)}
onPressIn={pressButtonIn}
onPressOut={pressButtonOut}
>
<Animated.View style={[styles.heroButton, { transform: [{ scale: buttonScale }] }]}>
<Text style={styles.heroButtonText}>Смотреть</Text>
</Animated.View>
</Pressable>
</View>
</Animated.View>
)}
<Row title="Онгоинги сегодня" releases={today.map((s) => s.release)} />
<Row title="Последние обновления" releases={latest} />
<Row title="Рекомендуем" releases={recommended} />
<Footer />
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
hero: {
width: '100%',
aspectRatio: 3 / 4,
maxHeight: 460,
backgroundColor: colors.surface,
},
heroImage: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
heroOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(18,18,18,0.35)',
},
heroOverlayBottom: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '65%',
backgroundColor: colors.background,
opacity: 0.85,
},
heroContent: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: spacing(4),
},
heroBadge: {
backgroundColor: colors.accent,
paddingHorizontal: spacing(2),
paddingVertical: spacing(1),
borderRadius: radius.sm,
alignSelf: 'flex-start',
marginBottom: spacing(2),
},
heroBadgeText: {
color: colors.text,
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
},
heroTitle: {
color: colors.text,
fontSize: 26,
fontWeight: '800',
lineHeight: 30,
},
heroDescription: {
marginTop: spacing(2),
color: colors.textDim,
fontSize: 14,
lineHeight: 19,
},
heroButton: {
marginTop: spacing(4),
alignSelf: 'flex-start',
backgroundColor: colors.accent,
paddingHorizontal: spacing(6),
paddingVertical: spacing(3),
borderRadius: radius.full,
},
heroButtonText: {
color: colors.text,
fontSize: 15,
fontWeight: '700',
},
})

265
app/(tabs)/profile.tsx Normal file
View file

@ -0,0 +1,265 @@
import { useRef } from 'react'
import { Animated, Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useAuth } from '../../src/lib/AuthContext'
import { imageUrl } from '../../src/lib/api'
import { colors, radius, spacing } from '../../src/lib/theme'
import Loader from '../../src/components/Loader'
import Footer from '../../src/components/Footer'
interface ProfileRowProps {
icon: keyof typeof Ionicons.glyphMap
label: string
onPress: () => void
danger?: boolean
}
function ProfileRow({ icon, label, onPress, danger }: ProfileRowProps) {
const scale = useRef(new Animated.Value(1)).current
function pressIn() {
Animated.spring(scale, { toValue: 0.97, useNativeDriver: true, speed: 50 }).start()
}
function pressOut() {
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 50 }).start()
}
return (
<Pressable onPress={onPress} onPressIn={pressIn} onPressOut={pressOut}>
<Animated.View style={[styles.row, { transform: [{ scale }] }]}>
<View style={styles.rowLeft}>
<Ionicons name={icon} size={19} color={danger ? colors.accent : colors.textDim} />
<Text style={[styles.rowLabel, danger && styles.rowLabelDanger]}>{label}</Text>
</View>
{!danger && <Ionicons name="chevron-forward" size={18} color={colors.textFaint} />}
</Animated.View>
</Pressable>
)
}
function LoginButton() {
const router = useRouter()
const scale = useRef(new Animated.Value(1)).current
function pressIn() {
Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 50 }).start()
}
function pressOut() {
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 50 }).start()
}
return (
<Pressable
onPress={() => router.push('/login')}
onPressIn={pressIn}
onPressOut={pressOut}
>
<Animated.View style={[styles.loginButton, { transform: [{ scale }] }]}>
<Text style={styles.loginButtonText}>Войти</Text>
</Animated.View>
</Pressable>
)
}
export default function ProfileScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const { user, loading, logout } = useAuth()
if (loading) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<Loader label="Загрузка..." />
</View>
)
}
const avatarUri = user
? imageUrl(user.avatar?.optimized?.preview || user.avatar?.preview)
: undefined
return (
<ScrollView
style={[styles.container, { paddingTop: insets.top }]}
contentContainerStyle={{ paddingBottom: insets.bottom + spacing(6) }}
>
<Text style={styles.title}>Профиль</Text>
{user ? (
<View style={styles.userHeader}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatar} />
) : (
<View style={styles.avatarFallback}>
<Text style={styles.avatarFallbackText}>
{user.nickname?.[0]?.toUpperCase() ?? '?'}
</Text>
</View>
)}
<Text style={styles.nickname}>{user.nickname}</Text>
</View>
) : (
<View style={styles.loggedOutWrap}>
<View style={styles.loggedOutIconCircle}>
<Ionicons name="person" size={30} color={colors.textDim} />
</View>
<Text style={styles.loggedOutTitle}>Вы не вошли в аккаунт</Text>
<Text style={styles.loggedOutText}>
Войдите в аккаунт AniLiberty, чтобы синхронизировать избранное, списки и историю
просмотра между устройствами
</Text>
<LoginButton />
</View>
)}
<View style={styles.section}>
<ProfileRow
icon="star-outline"
label="Понравилось"
onPress={() => router.push('/wishlist')}
/>
{user && (
<>
<ProfileRow
icon="list-outline"
label="Мои списки"
onPress={() => router.push('/account/lists')}
/>
<ProfileRow
icon="time-outline"
label="История"
onPress={() => router.push('/account/history')}
/>
</>
)}
</View>
{user && (
<View style={styles.section}>
<ProfileRow icon="log-out-outline" label="Выйти" onPress={logout} danger />
</View>
)}
<Footer />
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
title: {
color: colors.text,
fontSize: 26,
fontWeight: '800',
paddingHorizontal: spacing(4),
marginTop: spacing(2),
marginBottom: spacing(4),
},
userHeader: {
alignItems: 'center',
paddingHorizontal: spacing(4),
marginBottom: spacing(6),
},
avatar: {
width: 84,
height: 84,
borderRadius: radius.full,
backgroundColor: colors.surfaceRaised,
},
avatarFallback: {
width: 84,
height: 84,
borderRadius: radius.full,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
avatarFallbackText: {
color: colors.text,
fontSize: 32,
fontWeight: '700',
},
nickname: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
marginTop: spacing(3),
},
loggedOutWrap: {
alignItems: 'center',
paddingHorizontal: spacing(6),
marginBottom: spacing(6),
},
loggedOutIconCircle: {
width: 68,
height: 68,
borderRadius: radius.full,
backgroundColor: colors.surfaceRaised,
alignItems: 'center',
justifyContent: 'center',
},
loggedOutTitle: {
color: colors.text,
fontSize: 16,
fontWeight: '700',
marginTop: spacing(4),
textAlign: 'center',
},
loggedOutText: {
color: colors.textFaint,
fontSize: 13,
textAlign: 'center',
marginTop: spacing(2),
lineHeight: 18,
},
loginButton: {
marginTop: spacing(5),
backgroundColor: colors.accent,
borderRadius: radius.full,
paddingHorizontal: spacing(8),
paddingVertical: spacing(3),
},
loginButtonText: {
color: colors.text,
fontSize: 15,
fontWeight: '700',
},
section: {
marginHorizontal: spacing(4),
marginBottom: spacing(4),
backgroundColor: colors.surfaceCard,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing(4),
paddingVertical: spacing(4),
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
rowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
},
rowLabel: {
color: colors.text,
fontSize: 15,
fontWeight: '500',
},
rowLabelDanger: {
color: colors.accent,
},
})

82
app/(tabs)/schedule.tsx Normal file
View file

@ -0,0 +1,82 @@
import { useCallback, useEffect, useState } from 'react'
import { RefreshControl, ScrollView, StyleSheet, Text } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { api } from '../../src/lib/api'
import type { Release, ScheduleDay } from '../../src/lib/types'
import { colors, spacing } from '../../src/lib/theme'
import Loader from '../../src/components/Loader'
import Row from '../../src/components/Row'
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]
export default function ScheduleScreen() {
const insets = useSafeAreaInsets()
const [week, setWeek] = useState<ScheduleDay[] | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
try {
setWeek(await api.scheduleWeek())
} catch {
setWeek([])
}
}, [])
useEffect(() => {
load()
}, [load])
async function onRefresh() {
setRefreshing(true)
await load()
setRefreshing(false)
}
if (!week) return <Loader label="Загружаем расписание..." />
const byDay = new Map<number, Release[]>()
for (const item of week) {
if (!item.release) continue
const day = item.release.publish_day?.value ?? 0
if (!byDay.has(day)) byDay.set(day, [])
byDay.get(day)!.push(item.release)
}
return (
<ScrollView
style={styles.screen}
contentContainerStyle={{
paddingTop: insets.top + spacing(4),
paddingBottom: insets.bottom + spacing(8),
}}
refreshControl={
<RefreshControl tintColor={colors.accent} refreshing={refreshing} onRefresh={onRefresh} />
}
>
<Text style={styles.title}>Расписание выхода серий</Text>
{DAY_ORDER.filter((day) => byDay.has(day)).map((day) => {
const releases = byDay.get(day)!
return (
<Row
key={day}
title={releases[0].publish_day?.description ?? ''}
releases={releases}
/>
)
})}
</ScrollView>
)
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.background,
},
title: {
color: colors.text,
fontSize: 22,
fontWeight: '800',
paddingHorizontal: spacing(4),
},
})

227
app/account/history.tsx Normal file
View file

@ -0,0 +1,227 @@
import { useCallback, useEffect, useState } from 'react'
import { FlatList, Image, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { HistoryItem } from '../../src/lib/types'
import { api, imageUrl } from '../../src/lib/api'
import { useAuth } from '../../src/lib/AuthContext'
import { colors, radius, spacing } from '../../src/lib/theme'
import Loader from '../../src/components/Loader'
function formatDate(iso: string) {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export default function HistoryScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { user, loading: authLoading } = useAuth()
const [items, setItems] = useState<HistoryItem[] | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(() => {
return api
.historyList()
.then((res) => setItems(res.data.filter((item) => item.release && item.release_episode)))
.catch(() => setItems([]))
}, [])
useEffect(() => {
if (!user) return
load()
}, [user, load])
async function onRefresh() {
setRefreshing(true)
await load()
setRefreshing(false)
}
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + spacing(2) }]}>
<Pressable onPress={() => router.back()} hitSlop={10} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>История просмотра</Text>
</View>
{authLoading && <Loader />}
{!authLoading && !user && (
<View style={styles.empty}>
<Text style={styles.emptyText}>Войдите, чтобы видеть историю просмотра</Text>
<Pressable style={styles.loginButton} onPress={() => router.push('/login')}>
<Text style={styles.loginButtonText}>Войти</Text>
</Pressable>
</View>
)}
{!authLoading && user && !items && <Loader />}
{!authLoading && user && items && items.length === 0 && (
<View style={styles.empty}>
<Text style={styles.emptyText}>История пуста</Text>
</View>
)}
{!authLoading && user && items && items.length > 0 && (
<FlatList
data={items}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={[styles.list, { paddingBottom: insets.bottom + spacing(6) }]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} colors={[colors.accent]} />
}
renderItem={({ item }) => {
const thumb = imageUrl(
item.release.poster?.optimized?.thumbnail || item.release.poster?.thumbnail
)
return (
<Pressable
style={styles.row}
onPress={() => router.push(`/title/${item.release.alias || item.release.id}`)}
>
{thumb && <Image source={{ uri: thumb }} style={styles.thumb} resizeMode="cover" />}
<View style={styles.rowInfo}>
<Text numberOfLines={1} style={styles.rowTitle}>
{item.release.name.main}
</Text>
<Text numberOfLines={1} style={styles.rowMeta}>
Серия {item.release_episode.ordinal}
{item.release_episode.name ? ` — ${item.release_episode.name}` : ''}
</Text>
<Text style={styles.rowDate}>{formatDate(item.updated_at)}</Text>
</View>
{item.is_watched ? (
<View style={styles.badgeMuted}>
<Text style={styles.badgeMutedText}>Просмотрено</Text>
</View>
) : (
<View style={styles.badgeAccent}>
<Text style={styles.badgeAccentText}>В процессе</Text>
</View>
)}
</Pressable>
)
}}
/>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
paddingHorizontal: spacing(4),
paddingBottom: spacing(3),
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surfaceRaised,
},
headerTitle: {
color: colors.text,
fontSize: 19,
fontWeight: '700',
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing(8),
gap: spacing(4),
},
emptyText: {
color: colors.textFaint,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
loginButton: {
backgroundColor: colors.accent,
paddingHorizontal: spacing(6),
paddingVertical: spacing(3),
borderRadius: 999,
},
loginButtonText: {
color: colors.text,
fontSize: 14,
fontWeight: '700',
},
list: {
padding: spacing(4),
gap: spacing(2),
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
backgroundColor: colors.surfaceCard,
padding: spacing(2.5),
borderRadius: radius.md,
},
thumb: {
width: 48,
height: 64,
borderRadius: radius.sm,
backgroundColor: colors.surfaceRaised,
},
rowInfo: {
flex: 1,
minWidth: 0,
gap: spacing(0.5),
},
rowTitle: {
color: colors.text,
fontSize: 14,
fontWeight: '600',
},
rowMeta: {
color: colors.textFaint,
fontSize: 12,
},
rowDate: {
color: colors.textFaint,
fontSize: 11,
},
badgeMuted: {
backgroundColor: colors.surfaceRaised,
paddingHorizontal: spacing(2),
paddingVertical: spacing(1),
borderRadius: radius.sm,
},
badgeMutedText: {
color: colors.textDim,
fontSize: 10,
fontWeight: '700',
},
badgeAccent: {
backgroundColor: 'rgba(254,54,53,0.2)',
paddingHorizontal: spacing(2),
paddingVertical: spacing(1),
borderRadius: radius.sm,
},
badgeAccentText: {
color: colors.accent,
fontSize: 10,
fontWeight: '700',
},
})

209
app/account/lists.tsx Normal file
View file

@ -0,0 +1,209 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
FlatList,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
useWindowDimensions,
} from 'react-native'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { CollectionType, Release } from '../../src/lib/types'
import { api } from '../../src/lib/api'
import { useAuth } from '../../src/lib/AuthContext'
import { colors, spacing } from '../../src/lib/theme'
import AnimeCard from '../../src/components/AnimeCard'
import Loader from '../../src/components/Loader'
import Chip from '../../src/components/Chip'
type TabKey = CollectionType | 'FAVORITES'
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'WATCHING', label: 'Смотрю' },
{ key: 'PLANNED', label: 'Запланировано' },
{ key: 'WATCHED', label: 'Просмотрено' },
{ key: 'POSTPONED', label: 'Отложено' },
{ key: 'ABANDONED', label: 'Брошено' },
{ key: 'FAVORITES', label: 'Избранное' },
]
export default function MyListsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width } = useWindowDimensions()
const { user, loading: authLoading } = useAuth()
const [tab, setTab] = useState<TabKey>('WATCHING')
const [releases, setReleases] = useState<Release[] | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback((currentTab: TabKey) => {
const req =
currentTab === 'FAVORITES' ? api.favoritesList(1, 48) : api.collectionList(currentTab, 1, 48)
return req.then((res) => setReleases(res.data)).catch(() => setReleases([]))
}, [])
useEffect(() => {
if (!user) return
setReleases(null)
load(tab)
}, [tab, user, load])
async function onRefresh() {
setRefreshing(true)
await load(tab)
setRefreshing(false)
}
const numColumns = Math.max(3, Math.floor((width - spacing(4) * 2) / 120))
const paddedReleases = useMemo(() => {
if (!releases) return []
const remainder = releases.length % numColumns
if (remainder === 0) return releases
return [...releases, ...Array(numColumns - remainder).fill(null)]
}, [releases, numColumns])
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + spacing(2) }]}>
<Pressable onPress={() => router.back()} hitSlop={10} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>Мои списки</Text>
</View>
{authLoading && <Loader />}
{!authLoading && !user && (
<View style={styles.empty}>
<Text style={styles.emptyText}>Войдите, чтобы видеть свои списки</Text>
<Pressable style={styles.loginButton} onPress={() => router.push('/login')}>
<Text style={styles.loginButtonText}>Войти</Text>
</Pressable>
</View>
)}
{!authLoading && user && (
<>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.tabs}
>
{TABS.map((t) => (
<Pressable key={t.key} onPress={() => setTab(t.key)}>
<Chip accent={tab === t.key}>{t.label}</Chip>
</Pressable>
))}
</ScrollView>
{!releases && <Loader />}
{releases && releases.length === 0 && (
<View style={styles.empty}>
<Text style={styles.emptyText}>Список пуст</Text>
</View>
)}
{releases && releases.length > 0 && (
<FlatList
key={`grid-${numColumns}`}
data={paddedReleases}
numColumns={numColumns}
keyExtractor={(item, index) => (item ? String(item.id) : `empty-${index}`)}
columnWrapperStyle={styles.row}
contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + spacing(6) }]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
colors={[colors.accent]}
/>
}
renderItem={({ item }) =>
item ? (
<View style={styles.gridItem}>
<AnimeCard release={item} onPress={() => router.push(`/title/${item.alias || item.id}`)} />
</View>
) : (
<View style={styles.gridItem} />
)
}
/>
)}
</>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
paddingHorizontal: spacing(4),
paddingBottom: spacing(3),
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surfaceRaised,
},
headerTitle: {
color: colors.text,
fontSize: 19,
fontWeight: '700',
},
tabs: {
gap: spacing(2),
paddingHorizontal: spacing(4),
paddingBottom: spacing(3),
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing(8),
gap: spacing(4),
},
emptyText: {
color: colors.textFaint,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
loginButton: {
backgroundColor: colors.accent,
paddingHorizontal: spacing(6),
paddingVertical: spacing(3),
borderRadius: 999,
},
loginButtonText: {
color: colors.text,
fontSize: 14,
fontWeight: '700',
},
grid: {
padding: spacing(4),
gap: spacing(3),
},
row: {
gap: spacing(3),
},
gridItem: {
flex: 1,
},
})

176
app/login.tsx Normal file
View file

@ -0,0 +1,176 @@
import { useState } from 'react'
import {
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useAuth } from '../src/lib/AuthContext'
import { colors, radius, spacing } from '../src/lib/theme'
export default function LoginScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const { login, error, clearError } = useAuth()
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
async function onSubmit() {
if (!loginValue.trim() || !password) return
setSubmitting(true)
try {
await login(loginValue.trim(), password)
router.back()
} catch {
} finally {
setSubmitting(false)
}
}
return (
<KeyboardAvoidingView
style={[styles.container, { paddingTop: insets.top + spacing(4) }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<Pressable
hitSlop={12}
onPress={() => router.back()}
style={[styles.closeButton, { top: insets.top + spacing(2) }]}
>
<Ionicons name="close" size={22} color={colors.textDim} />
</Pressable>
<View style={styles.content}>
<Text style={styles.title}>Вход в AniLiberty</Text>
<Text style={styles.subtitle}>
Используй логин и пароль своего аккаунта на aniliberty.top
</Text>
<View style={styles.form}>
<TextInput
value={loginValue}
onChangeText={(v) => {
setLoginValue(v)
clearError()
}}
placeholder="Логин или email"
placeholderTextColor={colors.textFaint}
autoCapitalize="none"
autoCorrect={false}
autoFocus
style={styles.input}
/>
<TextInput
value={password}
onChangeText={(v) => {
setPassword(v)
clearError()
}}
placeholder="Пароль"
placeholderTextColor={colors.textFaint}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
</View>
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
onPress={onSubmit}
disabled={submitting || !loginValue.trim() || !password}
style={({ pressed }) => [
styles.submitButton,
(submitting || !loginValue.trim() || !password) && styles.submitButtonDisabled,
pressed && styles.submitButtonPressed,
]}
>
<Text style={styles.submitText}>{submitting ? '...' : 'Войти'}</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
closeButton: {
position: 'absolute',
right: spacing(4),
zIndex: 1,
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: colors.surfaceRaised,
alignItems: 'center',
justifyContent: 'center',
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: spacing(6),
},
title: {
color: colors.text,
fontSize: 22,
fontWeight: '700',
textAlign: 'center',
},
subtitle: {
color: colors.textFaint,
fontSize: 13,
textAlign: 'center',
marginTop: spacing(2),
lineHeight: 18,
},
form: {
marginTop: spacing(8),
gap: spacing(3),
},
input: {
backgroundColor: colors.surfaceCard,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
borderRadius: radius.md,
paddingHorizontal: spacing(4),
paddingVertical: spacing(3),
color: colors.text,
fontSize: 15,
},
error: {
color: colors.accent,
fontSize: 13,
textAlign: 'center',
marginTop: spacing(4),
},
submitButton: {
marginTop: spacing(6),
backgroundColor: colors.accent,
borderRadius: radius.full,
paddingVertical: spacing(3.5),
alignItems: 'center',
justifyContent: 'center',
},
submitButtonPressed: {
backgroundColor: colors.accentHover,
},
submitButtonDisabled: {
opacity: 0.4,
},
submitText: {
color: colors.text,
fontSize: 15,
fontWeight: '700',
},
})

143
app/wishlist.tsx Normal file
View file

@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { Release } from '../src/lib/types'
import { getWishlist, onWishlistChange } from '../src/lib/wishlist'
import { colors, spacing } from '../src/lib/theme'
import AnimeCard from '../src/components/AnimeCard'
import Loader from '../src/components/Loader'
export default function WishlistScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width } = useWindowDimensions()
const [items, setItems] = useState<Release[] | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(() => {
getWishlist().then(setItems)
}, [])
useEffect(() => {
load()
return onWishlistChange(load)
}, [load])
async function onRefresh() {
setRefreshing(true)
await load()
setRefreshing(false)
}
const numColumns = Math.max(3, Math.floor((width - spacing(4) * 2) / 120))
const paddedItems = useMemo(() => {
if (!items) return []
const remainder = items.length % numColumns
if (remainder === 0) return items
return [...items, ...Array(numColumns - remainder).fill(null)]
}, [items, numColumns])
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + spacing(2) }]}>
<Pressable onPress={() => router.back()} hitSlop={10} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<View>
<Text style={styles.headerTitle}>Понравилось</Text>
<Text style={styles.headerSubtitle}>Локальный список на этом устройстве</Text>
</View>
</View>
{!items && <Loader />}
{items && items.length === 0 && (
<View style={styles.empty}>
<Text style={styles.emptyText}>
Пока пусто — нажми на звёздочку у тайтла, чтобы добавить сюда
</Text>
</View>
)}
{items && items.length > 0 && (
<FlatList
key={`grid-${numColumns}`}
data={paddedItems}
numColumns={numColumns}
keyExtractor={(item, index) => (item ? String(item.id) : `empty-${index}`)}
columnWrapperStyle={styles.row}
contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + spacing(6) }]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} colors={[colors.accent]} />
}
renderItem={({ item }) =>
item ? (
<View style={styles.gridItem}>
<AnimeCard release={item} onPress={() => router.push(`/title/${item.alias || item.id}`)} />
</View>
) : (
<View style={styles.gridItem} />
)
}
/>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
paddingHorizontal: spacing(4),
paddingBottom: spacing(3),
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surfaceRaised,
},
headerTitle: {
color: colors.text,
fontSize: 19,
fontWeight: '700',
},
headerSubtitle: {
color: colors.textFaint,
fontSize: 11,
marginTop: spacing(0.5),
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing(8),
},
emptyText: {
color: colors.textFaint,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
grid: {
padding: spacing(4),
gap: spacing(3),
},
row: {
gap: spacing(3),
},
gridItem: {
flex: 1,
},
})

View file

@ -29,7 +29,8 @@
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"private": true
}

View file

@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react'
import { Animated, Image, Pressable, StyleSheet, Text, View } from 'react-native'
import Ionicons from '@expo/vector-icons/Ionicons'
import type { Release } from '../lib/types'
import { imageUrl } from '../lib/api'
import { colors, radius, spacing } from '../lib/theme'
import { isInWishlist, onWishlistChange, toggleWishlist } from '../lib/wishlist'
interface AnimeCardProps {
release: Release
onPress?: () => void
}
export default function AnimeCard({ release, onPress }: AnimeCardProps) {
const [saved, setSaved] = useState(false)
const scale = useRef(new Animated.Value(1)).current
useEffect(() => {
let mounted = true
isInWishlist(release.id).then((v) => mounted && setSaved(v))
const unsubscribe = onWishlistChange(() => {
isInWishlist(release.id).then((v) => mounted && setSaved(v))
})
return () => {
mounted = false
unsubscribe()
}
}, [release.id])
function pressIn() {
Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 50 }).start()
}
function pressOut() {
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 50 }).start()
}
async function handleToggleWishlist() {
const nowSaved = await toggleWishlist(release)
setSaved(nowSaved)
}
const poster = imageUrl(release.poster?.optimized?.preview || release.poster?.preview)
return (
<Pressable onPress={onPress} onPressIn={pressIn} onPressOut={pressOut}>
<Animated.View style={[styles.container, { transform: [{ scale }] }]}>
<View style={styles.posterWrap}>
{poster && <Image source={{ uri: poster }} style={styles.poster} resizeMode="cover" />}
<View style={styles.topLeftCol}>
{release.is_ongoing && (
<View style={styles.badgeAccent}>
<Text style={styles.badgeText}>ONGOING</Text>
</View>
)}
{release.episodes_total != null && (
<View style={styles.badgeMuted}>
<Text style={styles.badgeText}>{release.episodes_total} эп.</Text>
</View>
)}
</View>
<Pressable hitSlop={10} onPress={handleToggleWishlist} style={styles.starButton}>
<Ionicons
name={saved ? 'star' : 'star-outline'}
size={15}
color={saved ? colors.accent : colors.text}
/>
</Pressable>
</View>
<Text numberOfLines={2} style={styles.title}>
{release.name.main}
</Text>
<Text numberOfLines={1} style={styles.meta}>
{release.season?.description} {release.year} · {release.type?.description}
</Text>
</Animated.View>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
posterWrap: {
width: '100%',
aspectRatio: 2 / 3,
borderRadius: radius.md,
overflow: 'hidden',
backgroundColor: colors.surfaceRaised,
},
poster: {
width: '100%',
height: '100%',
},
topLeftCol: {
position: 'absolute',
top: spacing(1.5),
left: spacing(1.5),
gap: spacing(1),
},
badgeAccent: {
backgroundColor: colors.accent,
paddingHorizontal: spacing(1.5),
paddingVertical: spacing(0.5),
borderRadius: radius.sm,
alignSelf: 'flex-start',
},
badgeMuted: {
backgroundColor: colors.overlay,
paddingHorizontal: spacing(1.5),
paddingVertical: spacing(0.5),
borderRadius: radius.sm,
alignSelf: 'flex-start',
},
badgeText: {
color: colors.text,
fontSize: 9,
fontWeight: '700',
textTransform: 'uppercase',
},
starButton: {
position: 'absolute',
top: spacing(1.5),
right: spacing(1.5),
width: 26,
height: 26,
borderRadius: radius.full,
backgroundColor: colors.overlay,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: spacing(2),
color: colors.text,
fontSize: 13,
fontWeight: '600',
lineHeight: 16,
},
meta: {
marginTop: spacing(1),
color: colors.textFaint,
fontSize: 11,
},
})

40
src/components/Chip.tsx Normal file
View file

@ -0,0 +1,40 @@
import { ReactNode } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { colors, radius, spacing } from '../lib/theme'
interface ChipProps {
children: ReactNode
accent?: boolean
}
export default function Chip({ children, accent }: ChipProps) {
return (
<View style={[styles.chip, accent && styles.chipAccent]}>
<Text style={[styles.text, accent && styles.textAccent]}>{children}</Text>
</View>
)
}
const styles = StyleSheet.create({
chip: {
backgroundColor: colors.surfaceRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
paddingHorizontal: spacing(3),
paddingVertical: spacing(1.5),
borderRadius: radius.full,
alignSelf: 'flex-start',
},
chipAccent: {
backgroundColor: colors.accent,
borderColor: colors.accent,
},
text: {
color: colors.textDim,
fontSize: 12,
fontWeight: '600',
},
textAccent: {
color: colors.text,
},
})

29
src/components/Loader.tsx Normal file
View file

@ -0,0 +1,29 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { colors, spacing } from '../lib/theme'
interface LoaderProps {
label?: string
}
export default function Loader({ label }: LoaderProps) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={colors.accent} />
{label && <Text style={styles.label}>{label}</Text>}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing(24),
gap: spacing(3),
},
label: {
color: colors.textDim,
fontSize: 13,
},
})

59
src/components/Row.tsx Normal file
View file

@ -0,0 +1,59 @@
import { FlatList, StyleSheet, Text, View } from 'react-native'
import { useRouter } from 'expo-router'
import type { Release } from '../lib/types'
import { colors, spacing } from '../lib/theme'
import AnimeCard from './AnimeCard'
interface RowProps {
title: string
releases: Release[]
}
const CARD_WIDTH = 132
export default function Row({ title, releases }: RowProps) {
const router = useRouter()
if (!releases.length) return null
return (
<View style={styles.section}>
<Text style={styles.heading}>{title}</Text>
<FlatList
horizontal
data={releases}
keyExtractor={(item) => String(item.id)}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.item}>
<AnimeCard
release={item}
onPress={() => router.push(`/title/${item.alias || item.id}`)}
/>
</View>
)}
/>
</View>
)
}
const styles = StyleSheet.create({
section: {
marginTop: spacing(4),
},
heading: {
color: colors.text,
fontSize: 17,
fontWeight: '700',
marginBottom: spacing(2),
paddingHorizontal: spacing(4),
},
list: {
paddingHorizontal: spacing(4),
gap: spacing(3),
},
item: {
width: CARD_WIDTH,
},
})

28
src/lib/playerPrefs.ts Normal file
View file

@ -0,0 +1,28 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const KEY = 'anisav:playerPrefs'
export interface PlayerPrefs {
autoSkipOpening: boolean
autoSkipEnding: boolean
autoPlayNext: boolean
}
const DEFAULTS: PlayerPrefs = {
autoSkipOpening: false,
autoSkipEnding: false,
autoPlayNext: true,
}
export async function loadPlayerPrefs(): Promise<PlayerPrefs> {
try {
const raw = await AsyncStorage.getItem(KEY)
return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS
} catch {
return DEFAULTS
}
}
export async function savePlayerPrefs(prefs: PlayerPrefs): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(prefs))
}