diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx new file mode 100644 index 0000000..31ed1d9 --- /dev/null +++ b/mobile/src/components/SceneTile.tsx @@ -0,0 +1,216 @@ +/** + * SceneTile — 2-col 16:9 grid item używany w listach scen + * (Scenes, SiteScenes, PerformerScenes, StudioScenes, TagScenes). + * + * Per impeccable.style/slop + Jan feedback "większe miniaturki, mniej tekstu": + * - Thumb wypełnia szerokość kolumny (aspect 16:9) + * - Title 1 linijka, weight 600, letter-spacing -0.2 + * - Pod tytułem: 1 linia uppercase micro-meta (studio | performers | tag — wybór per ekran) + * - Overlay na thumb: fav (top-left), duration (bottom-right), NEW (top-right gdy seenSince), + * ✓watched (top-right gdy finished) + * - Long-press → animated preview (gdy playback_source ma animated_thumbnail_url) + * + * Zostawia Pressable do parenta dla custom onLongPress (np. delete-from-favorites) — + * default onLongPress robi preview (jak w ScenesScreen). + * + * Used inline w 2-column FlatList: + * } + * /> + */ +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import * as Haptics from 'expo-haptics'; +import React, { useState } 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'; + +export type SecondLine = 'studio' | 'performers' | 'date' | 'none'; + +interface Props { + scene: SceneOut; + secondLine?: SecondLine; + /** + * Pokazuj NEW badge gdy scene.created_at > seenSince. Używane na ekranach + * Performer/Studio scenes — gdy user owner'ował fav, last_seen_at z favorite + * jest porównywany ze sceną. + */ + seenSince?: string; + /** + * Custom long-press handler (np. removal from favorites). Default = animated + * preview gdy thumb ma animated wariant. + */ + onLongPress?: () => void; +} + +export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) { + const navigation = + useNavigation>(); + const [isPreviewing, setIsPreviewing] = 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 (onLongPress) { + onLongPress(); + return; + } + if (!animatedUrl) return; + setIsPreviewing(true); + Haptics.selectionAsync().catch(() => {}); + }; + + const dim = scene.finished === true; + const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince); + const dur = scene.duration_sec; + const durLabel = + dur && dur > 0 + ? dur >= 3600 + ? `${Math.floor(dur / 3600)}h${String(Math.floor((dur % 3600) / 60)).padStart(2, '0')}` + : `${Math.floor(dur / 60)}m` + : null; + + const meta = (() => { + if (secondLine === 'none') return null; + if (secondLine === 'studio') return scene.studio?.name || null; + if (secondLine === 'performers') { + if (scene.performers.length === 0) return null; + const names = scene.performers.slice(0, 2).map((p) => p.canonical_name).join(', '); + return scene.performers.length > 2 ? `${names} +${scene.performers.length - 2}` : names; + } + if (secondLine === 'date') return scene.release_date; + return null; + })(); + + return ( + navigation.navigate('SceneDetail', { id: scene.id })} + onLongPress={startPreview} + onPressOut={() => setIsPreviewing(false)} + delayLongPress={180} + > + + + {scene.is_favorite ? ( + + + + ) : null} + {isNew ? ( + + NEW + + ) : null} + {dim ? ( + + + + ) : null} + {durLabel ? ( + + {durLabel} + + ) : null} + + + {scene.title} + + {meta ? ( + + {meta} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + tile: { flex: 1 }, + thumbWrap: { + width: '100%', + aspectRatio: 16 / 9, + borderRadius: 6, + overflow: 'hidden', + position: 'relative', + backgroundColor: theme.bgElevated, + }, + thumb: { width: '100%', height: '100%' }, + thumbDim: { opacity: 0.45 }, + favBadge: { + position: 'absolute', + top: 6, + left: 6, + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 5, + paddingVertical: 1, + borderRadius: 6, + }, + favBadgeText: { color: theme.accent, fontSize: 11, fontWeight: '700' }, + newBadge: { + position: 'absolute', + top: 6, + right: 6, + backgroundColor: theme.accent, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + newBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 }, + watchedBadge: { + position: 'absolute', + top: 6, + right: 6, + backgroundColor: 'rgba(0,0,0,0.78)', + width: 20, + height: 20, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, + watchedText: { color: theme.fg, fontSize: 11, fontWeight: '700' }, + durBadge: { + position: 'absolute', + bottom: 6, + right: 6, + backgroundColor: 'rgba(0,0,0,0.78)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + durText: { + color: theme.fg, + fontSize: 11, + fontWeight: '600', + fontVariant: ['tabular-nums'], + }, + title: { + color: theme.fg, + fontSize: 14, + fontWeight: '600', + marginTop: 8, + letterSpacing: -0.2, + }, + titleDim: { color: theme.muted }, + meta: { + color: theme.muted, + fontSize: 11, + marginTop: 2, + letterSpacing: 0.3, + textTransform: 'uppercase', + }, +}); + +export const sceneTileGridProps = { + numColumns: 2 as const, + columnWrapperStyle: { gap: 10, marginBottom: 14 }, +}; diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index a2e5891..ff5594c 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -20,6 +20,7 @@ import { import { useClient } from '../ClientContext'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { MoviePosterCard } from '../components/MoviePosterCard'; +import { SceneTile } from '../components/SceneTile'; import { ErrorBoundary } from '../ErrorBoundary'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -270,9 +271,11 @@ export function PerformerScenesScreen() { key="scenes-list" data={sortedScenes} keyExtractor={(s) => s.id} + numColumns={2} renderItem={({ item }) => ( - + )} + columnWrapperStyle={styles.gridRow} refreshing={scenesQuery.isRefetching} onRefresh={scenesQuery.refetch} ListHeaderComponent={ @@ -417,6 +420,7 @@ function TabButton({ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, + gridRow: { gap: 10, marginBottom: 14 }, header: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index 1182bfe..cbf44fa 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -14,6 +14,7 @@ import { } from 'react-native'; import { Image } from 'expo-image'; import * as Haptics from 'expo-haptics'; +import { SceneTile } from '../components/SceneTile'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; import { theme } from '../theme'; @@ -223,80 +224,6 @@ function FavoritesButton() { ); } -/** - * SceneTile — 2-col 16:9 grid item, minimal text (video portal). - * - * Per impeccable.style/slop + Jan feedback "większe miniaturki, mniej tekstu": - * - Thumb wypełnia szerokość kolumny (16:9 aspect ratio) - * - Title 1 linijka, system font, weight 600 - * - Overlay: studio (mono small) + duration → tylko na thumb, NIE pod - * - Wyrzucone: performers, release_date, sources count, "✓ watched" string - * (zastąpione check badge na thumb) - * - Long-press → animated preview (zachowane) - */ -function SceneTile({ scene }: { scene: SceneOut }) { - const navigation = - useNavigation>(); - const [isPreviewing, setIsPreviewing] = 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 dim = scene.finished === true; - const dur = scene.duration_sec; - const durLabel = - dur && dur > 0 - ? dur >= 3600 - ? `${Math.floor(dur / 3600)}h${String(Math.floor((dur % 3600) / 60)).padStart(2, '0')}` - : `${Math.floor(dur / 60)}m` - : null; - - return ( - navigation.navigate('SceneDetail', { id: scene.id })} - onLongPress={startPreview} - onPressOut={() => setIsPreviewing(false)} - delayLongPress={180} - > - - - {scene.is_favorite ? ( - - - - ) : null} - {durLabel ? ( - - {durLabel} - - ) : null} - {dim ? ( - - - - ) : null} - - - {scene.title} - - {scene.studio?.name ? ( - - {scene.studio.name} - - ) : null} - - ); -} - function SceneRow({ scene }: { scene: SceneOut }) { const navigation = useNavigation>(); @@ -506,71 +433,7 @@ const styles = StyleSheet.create({ lineHeight: 15, }, - // 2-col grid (SceneTile) — wprowadzone 2026-05-29 (UI overhaul, Jan feedback - // "większe miniaturki, mniej tekstu"). Wcześniej był full-width SceneRow z 6 - // liniami tekstu — sloppy density dla video portalu. + // 2-col grid wrapper — SceneTile sam ma styling tile/thumb/etc, tu tylko + // odstęp między tilami w wierszu i pod wierszem. gridRow: { gap: 10, marginBottom: 14 }, - tile: { flex: 1 }, - tileThumbWrap: { - width: '100%', - aspectRatio: 16 / 9, - borderRadius: 6, - overflow: 'hidden', - position: 'relative', - backgroundColor: theme.bgElevated, - }, - tileThumb: { width: '100%', height: '100%' }, - tileThumbDim: { opacity: 0.45 }, - tileFavBadge: { - position: 'absolute', - top: 6, - left: 6, - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 5, - paddingVertical: 1, - borderRadius: 6, - }, - tileFavBadgeText: { color: theme.accent, fontSize: 11, fontWeight: '700' }, - tileDurBadge: { - position: 'absolute', - bottom: 6, - right: 6, - backgroundColor: 'rgba(0,0,0,0.78)', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - }, - tileDurText: { - color: theme.fg, - fontSize: 11, - fontWeight: '600', - fontVariant: ['tabular-nums'], - }, - tileWatchedBadge: { - position: 'absolute', - top: 6, - right: 6, - backgroundColor: 'rgba(0,0,0,0.78)', - width: 20, - height: 20, - borderRadius: 999, - alignItems: 'center', - justifyContent: 'center', - }, - tileWatchedText: { color: theme.fg, fontSize: 11, fontWeight: '700' }, - tileTitle: { - color: theme.fg, - fontSize: 14, - fontWeight: '600', - marginTop: 8, - letterSpacing: -0.2, - }, - tileTitleDim: { color: theme.muted }, - tileStudio: { - color: theme.muted, - fontSize: 11, - marginTop: 2, - letterSpacing: 0.3, - textTransform: 'uppercase', - }, }); diff --git a/mobile/src/screens/SiteScenesScreen.tsx b/mobile/src/screens/SiteScenesScreen.tsx index 9ab0935..9e8c4ce 100644 --- a/mobile/src/screens/SiteScenesScreen.tsx +++ b/mobile/src/screens/SiteScenesScreen.tsx @@ -22,6 +22,7 @@ import { TextInput, View, } from 'react-native'; +import { SceneTile } from '../components/SceneTile'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; @@ -107,7 +108,9 @@ export function SiteScenesScreen() { s.id} - renderItem={({ item }) => } + numColumns={2} + renderItem={({ item }) => } + columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} onEndReached={() => { @@ -353,6 +356,7 @@ const styles = StyleSheet.create({ muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 }, error: { color: theme.bad, padding: 12 }, toolbar: { flexDirection: 'row', gap: 8, marginBottom: 8, alignItems: 'center' }, + gridRow: { gap: 10, marginBottom: 14 }, filterBtn: { paddingHorizontal: 14, paddingVertical: 8, diff --git a/mobile/src/screens/StudioScenesScreen.tsx b/mobile/src/screens/StudioScenesScreen.tsx index 53afd02..bec9dce 100644 --- a/mobile/src/screens/StudioScenesScreen.tsx +++ b/mobile/src/screens/StudioScenesScreen.tsx @@ -16,6 +16,7 @@ import { View, } from 'react-native'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; +import { SceneTile } from '../components/SceneTile'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -136,9 +137,11 @@ export function StudioScenesScreen() { s.id} + numColumns={2} renderItem={({ item }) => ( - + )} + columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} ListHeaderComponent={ @@ -157,6 +160,7 @@ export function StudioScenesScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, + gridRow: { gap: 10, marginBottom: 14 }, header: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/mobile/src/screens/TagScenesScreen.tsx b/mobile/src/screens/TagScenesScreen.tsx index c733d64..c5945e3 100644 --- a/mobile/src/screens/TagScenesScreen.tsx +++ b/mobile/src/screens/TagScenesScreen.tsx @@ -11,6 +11,7 @@ import { Text, View, } from 'react-native'; +import { SceneTile } from '../components/SceneTile'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -46,7 +47,9 @@ export function TagScenesScreen() { s.id} - renderItem={({ item }) => } + numColumns={2} + renderItem={({ item }) => } + columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} ListHeaderComponent={ @@ -99,6 +102,7 @@ function SceneRow({ scene }: { scene: SceneOut }) { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, + gridRow: { gap: 10, marginBottom: 14 }, subtitle: { color: theme.muted, marginBottom: 8, paddingHorizontal: 4 }, row: { backgroundColor: theme.card,