ci: typecheck on push
This commit is contained in:
parent
3c407248a7
commit
144a2cd227
17 changed files with 2345 additions and 1 deletions
18
.github/workflows/ci.yml
vendored
Normal file
18
.github/workflows/ci.yml
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
525
app/(tabs)/catalog.tsx
Normal file
525
app/(tabs)/catalog.tsx
Normal 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
184
app/(tabs)/genres.tsx
Normal 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
209
app/(tabs)/index.tsx
Normal 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
265
app/(tabs)/profile.tsx
Normal 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
82
app/(tabs)/schedule.tsx
Normal 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
227
app/account/history.tsx
Normal 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
209
app/account/lists.tsx
Normal 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
176
app/login.tsx
Normal 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
143
app/wishlist.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
148
src/components/AnimeCard.tsx
Normal file
148
src/components/AnimeCard.tsx
Normal 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
40
src/components/Chip.tsx
Normal 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
29
src/components/Loader.tsx
Normal 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
59
src/components/Row.tsx
Normal 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
28
src/lib/playerPrefs.ts
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue