diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ff4dea --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/app/(tabs)/catalog.tsx b/app/(tabs)/catalog.tsx new file mode 100644 index 0000000..6ceda02 --- /dev/null +++ b/app/(tabs)/catalog.tsx @@ -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([]) + 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', + }, +}) diff --git a/app/(tabs)/genres.tsx b/app/(tabs)/genres.tsx new file mode 100644 index 0000000..9ba39e9 --- /dev/null +++ b/app/(tabs)/genres.tsx @@ -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 ( + + + {image && } + + + + + {genre.name} + + {genre.total_releases != null && ( + {genre.total_releases} тайтлов + )} + + + + ) +} + +export default function GenresScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [genres, setGenres] = useState(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 + + return ( + String(item.id)} + numColumns={2} + columnWrapperStyle={styles.row} + contentContainerStyle={[ + styles.list, + { paddingTop: insets.top + spacing(4), paddingBottom: insets.bottom + spacing(8) }, + ]} + refreshControl={ + + } + ListHeaderComponent={Жанры} + renderItem={({ item }) => ( + + 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), + }, +}) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..58b7cc7 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -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(null) + const [recommended, setRecommended] = useState([]) + const [today, setToday] = useState([]) + 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 + } + + const hero = latest[0] + + return ( + + } + > + {hero && ( + + + + + + + + Новинка + + + {hero.name.main} + + {!!hero.description && ( + + {hero.description} + + )} + router.push(`/title/${hero.alias || hero.id}`)} + onPressIn={pressButtonIn} + onPressOut={pressButtonOut} + > + + Смотреть + + + + + )} + + s.release)} /> + + + +