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([]) const [refTypes, setRefTypes] = useState([]) const [refSeasons, setRefSeasons] = useState([]) const [refSorting, setRefSorting] = useState([]) 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([]) const [selectedTypes, setSelectedTypes] = useState([]) const [selectedSeasons, setSelectedSeasons] = useState([]) const [filtersVisible, setFiltersVisible] = useState(false) const [draftSorting, setDraftSorting] = useState(DEFAULT_SORTING) const [draftGenres, setDraftGenres] = useState([]) const [draftTypes, setDraftTypes] = useState([]) const [draftSeasons, setDraftSeasons] = useState([]) 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(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 ( {isSearching ? `Поиск: "${debouncedQuery}"` : 'Каталог'} {query.length > 0 && ( setQuery('')}> )} {!isSearching && ( Фильтры {activeFilterCount > 0 && ( {activeFilterCount} )} )} {loading ? ( ) : results && results.length === 0 ? ( Ничего не найдено ) : ( (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 }) => ( {item && ( router.push(`/title/${item.alias || item.id}`)} /> )} )} ListFooterComponent={ loadingMore ? ( ) : ( ) } /> )} setFiltersVisible(false)} > setFiltersVisible(false)} /> Фильтры setFiltersVisible(false)}> {refSorting.map((s) => ( setDraftSorting(s.value)}> {s.label} ))} {refTypes.map((t) => ( toggleDraftType(t.value)}> {t.description} ))} {refSeasons.map((s) => ( toggleDraftSeason(s.value)}> {s.description} ))} {refGenres.map((g) => ( toggleDraftGenre(g.id)}> {g.name} ))} Сбросить Применить ) } function FilterSection({ title, children }: { title: string; children: React.ReactNode }) { return ( {title} {children} ) } 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', }, })