goon/mobile/src/components/FavoriteSceneRow.tsx
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

146 lines
4.8 KiB
TypeScript

// Wspólny komponent karty sceny dla list ulubionych (PerformerScenesScreen +
// StudioScenesScreen). Identyczny shape co SceneRow w ScenesScreen — thumb
// po lewej, tytuł / data · studio / performers / sources w kolumnie po prawej.
// Dodatkowo: NEW badge gdy `seenSince` < scene.created_at.
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as Haptics from 'expo-haptics';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
import { Thumb } from './Thumb';
interface Props {
scene: SceneOut;
/** Pokazuje NEW badge gdy `scene.created_at > seenSince`. */
seenSince?: string;
/** Co wyświetlić w drugiej linijce — performers (dla studio scenes) lub studio (dla performer scenes). */
secondLine?: 'performers' | 'studio';
}
export function FavoriteSceneRow({ scene, seenSince, secondLine = 'studio' }: Props) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isPreviewing, setIsPreviewing] = React.useState(false);
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
?.animated_thumbnail_url;
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
const startPreview = () => {
if (!animatedUrl) return;
setIsPreviewing(true);
Haptics.selectionAsync().catch(() => {});
};
const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince);
const dim = scene.finished === true;
// Drugi rządek (data + studio LUB data + performers)
const dateStr = scene.release_date ?? null;
let secondLineText: string | null = null;
if (secondLine === 'studio') {
const studio = scene.studio?.name ?? null;
secondLineText = [dateStr, studio].filter(Boolean).join(' · ') || null;
} else {
const performers = scene.performers
.slice(0, 3)
.map((p) => p.canonical_name)
.join(', ');
secondLineText = [dateStr, performers].filter(Boolean).join(' · ') || null;
}
return (
<Pressable
style={[styles.row, dim && styles.rowDimmed]}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<Thumb url={displayUrl} style={styles.thumbnail} />
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
</View>
) : null}
{isNew ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
</View>
) : null}
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{scene.title}
</Text>
{secondLineText ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{secondLineText}
</Text>
) : null}
<Text style={styles.rowSources}>
{[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')}
{scene.playback_sources.length > 0
? `${scene.playback_sources.length}`
: ''}
{dim ? ' ✓ watched' : ''}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
padding: 10,
marginBottom: 10,
gap: 12,
position: 'relative',
},
rowDimmed: { opacity: 0.55 },
thumbnail: { width: 110, aspectRatio: 16 / 9, flexShrink: 0 },
rowContent: { flex: 1, justifyContent: 'center' },
rowTitle: { color: theme.fg, fontWeight: '600', marginBottom: 4 },
rowMuted: { color: theme.muted, fontSize: 13, marginBottom: 4 },
rowSources: {
color: theme.accent,
fontSize: 11,
textTransform: 'uppercase',
marginTop: 2,
},
favBadge: {
position: 'absolute',
top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
},
favBadgeText: { color: theme.accent, fontSize: 13, fontWeight: '800' },
newBadge: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 4,
elevation: 3,
},
newBadgeText: { color: theme.bg, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
});