style(mobile): SceneTile shared component, 2-col grid w 5 ekranach scen

Jan feedback po pierwszym overhaulu: layout 2-col tile pasuje, ale aktywnie
tylko na ScenesScreen - reszta ekranow scen (SiteScenes, PerformerScenes,
StudioScenes, TagScenes) dalej w full-width row layoucie.

Wyciagniety SceneTile do mobile/src/components/SceneTile.tsx ze wsparciem:
- secondLine: 'studio' | 'performers' | 'date' | 'none' - per-ekran dobor
  metadanej (Studio na SiteScenes/Performer, performers na Studio, etc)
- seenSince: ISO timestamp - pokazuje NEW badge gdy scene.created_at > seen
  (uzywane na Performer/Studio screens dla NEW od ostatniego markFavoriteSeen)
- onLongPress: opcjonalny custom handler (default = animated preview)

Refaktor 5 ekranow:
- ScenesScreen: usuwa lokalna kopie SceneTile, import shared
- SiteScenesScreen: SceneRow -> SceneTile (numColumns=2, secondLine='studio')
- PerformerScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2)
- StudioScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2, performers)
- TagScenesScreen: lokalna SceneRow -> SceneTile

FavoriteSceneRow component zostaje (legacy import w PerformerScenes - nie
ruszamy bo moze byc uzyty w innym kontekscie). gridRow style scaffold (gap+
marginBottom) dodany w kazdym StyleSheet osobno bo te ekrany maja rozne
paddingHorizontal w container.

OTA: 9eea7ac6-df72-460e-9660-22bf6c39c3ac live, runtime 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-05-29 15:16:09 +02:00
parent aa647dcf97
commit 177a45eee7
6 changed files with 239 additions and 144 deletions

View file

@ -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:
* <FlatList
* numColumns={2}
* columnWrapperStyle={{ gap: 10, marginBottom: 14 }}
* renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
* />
*/
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<NativeStackNavigationProp<RootStackParamList>>();
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 (
<Pressable
style={styles.tile}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<View style={[styles.thumbWrap, dim && styles.thumbDim]}>
<Thumb url={displayUrl} style={styles.thumb} />
{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}
{dim ? (
<View style={styles.watchedBadge}>
<Text style={styles.watchedText}></Text>
</View>
) : null}
{durLabel ? (
<View style={styles.durBadge}>
<Text style={styles.durText}>{durLabel}</Text>
</View>
) : null}
</View>
<Text style={[styles.title, dim && styles.titleDim]} numberOfLines={1}>
{scene.title}
</Text>
{meta ? (
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
) : null}
</Pressable>
);
}
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 },
};

View file

@ -20,6 +20,7 @@ import {
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { MoviePosterCard } from '../components/MoviePosterCard'; import { MoviePosterCard } from '../components/MoviePosterCard';
import { SceneTile } from '../components/SceneTile';
import { ErrorBoundary } from '../ErrorBoundary'; import { ErrorBoundary } from '../ErrorBoundary';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
@ -270,9 +271,11 @@ export function PerformerScenesScreen() {
key="scenes-list" key="scenes-list"
data={sortedScenes} data={sortedScenes}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
renderItem={({ item }) => ( renderItem={({ item }) => (
<FavoriteSceneRow scene={item} seenSince={seenSince} secondLine="studio" /> <SceneTile scene={item} seenSince={seenSince} secondLine="studio" />
)} )}
columnWrapperStyle={styles.gridRow}
refreshing={scenesQuery.isRefetching} refreshing={scenesQuery.isRefetching}
onRefresh={scenesQuery.refetch} onRefresh={scenesQuery.refetch}
ListHeaderComponent={ ListHeaderComponent={
@ -417,6 +420,7 @@ function TabButton({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',

View file

@ -14,6 +14,7 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { SceneTile } from '../components/SceneTile';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { theme } from '../theme'; 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<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
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 (
<Pressable
style={styles.tile}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<View style={[styles.tileThumbWrap, dim && styles.tileThumbDim]}>
<Thumb url={displayUrl} style={styles.tileThumb} />
{scene.is_favorite ? (
<View style={styles.tileFavBadge}>
<Text style={styles.tileFavBadgeText}></Text>
</View>
) : null}
{durLabel ? (
<View style={styles.tileDurBadge}>
<Text style={styles.tileDurText}>{durLabel}</Text>
</View>
) : null}
{dim ? (
<View style={styles.tileWatchedBadge}>
<Text style={styles.tileWatchedText}></Text>
</View>
) : null}
</View>
<Text style={[styles.tileTitle, dim && styles.tileTitleDim]} numberOfLines={1}>
{scene.title}
</Text>
{scene.studio?.name ? (
<Text style={styles.tileStudio} numberOfLines={1}>
{scene.studio.name}
</Text>
) : null}
</Pressable>
);
}
function SceneRow({ scene }: { scene: SceneOut }) { function SceneRow({ scene }: { scene: SceneOut }) {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
@ -506,71 +433,7 @@ const styles = StyleSheet.create({
lineHeight: 15, lineHeight: 15,
}, },
// 2-col grid (SceneTile) — wprowadzone 2026-05-29 (UI overhaul, Jan feedback // 2-col grid wrapper — SceneTile sam ma styling tile/thumb/etc, tu tylko
// "większe miniaturki, mniej tekstu"). Wcześniej był full-width SceneRow z 6 // odstęp między tilami w wierszu i pod wierszem.
// liniami tekstu — sloppy density dla video portalu.
gridRow: { gap: 10, marginBottom: 14 }, 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',
},
}); });

View file

@ -22,6 +22,7 @@ import {
TextInput, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SceneTile } from '../components/SceneTile';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
@ -107,7 +108,9 @@ export function SiteScenesScreen() {
<FlatList <FlatList
data={items} data={items}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
renderItem={({ item }) => <SceneRow scene={item} />} numColumns={2}
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
onEndReached={() => { onEndReached={() => {
@ -353,6 +356,7 @@ const styles = StyleSheet.create({
muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 },
error: { color: theme.bad, padding: 12 }, error: { color: theme.bad, padding: 12 },
toolbar: { flexDirection: 'row', gap: 8, marginBottom: 8, alignItems: 'center' }, toolbar: { flexDirection: 'row', gap: 8, marginBottom: 8, alignItems: 'center' },
gridRow: { gap: 10, marginBottom: 14 },
filterBtn: { filterBtn: {
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 8, paddingVertical: 8,

View file

@ -16,6 +16,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile } from '../components/SceneTile';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
@ -136,9 +137,11 @@ export function StudioScenesScreen() {
<FlatList <FlatList
data={sortedItems} data={sortedItems}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
renderItem={({ item }) => ( renderItem={({ item }) => (
<FavoriteSceneRow scene={item} seenSince={seenSince} secondLine="performers" /> <SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
)} )}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListHeaderComponent={ ListHeaderComponent={
@ -157,6 +160,7 @@ export function StudioScenesScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',

View file

@ -11,6 +11,7 @@ import {
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { SceneTile } from '../components/SceneTile';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
@ -46,7 +47,9 @@ export function TagScenesScreen() {
<FlatList <FlatList
data={data?.items ?? []} data={data?.items ?? []}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
renderItem={({ item }) => <SceneRow scene={item} />} numColumns={2}
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListHeaderComponent={ ListHeaderComponent={
@ -99,6 +102,7 @@ function SceneRow({ scene }: { scene: SceneOut }) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
subtitle: { color: theme.muted, marginBottom: 8, paddingHorizontal: 4 }, subtitle: { color: theme.muted, marginBottom: 8, paddingHorizontal: 4 },
row: { row: {
backgroundColor: theme.card, backgroundColor: theme.card,