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.
146 lines
4.8 KiB
TypeScript
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 },
|
|
});
|