ci: typecheck on push
This commit is contained in:
parent
3c407248a7
commit
144a2cd227
17 changed files with 2345 additions and 1 deletions
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