feat(mobile): tile long-press actions (hide / mark-duplicate), drop dead preview
bug-report 5a6844db: the hold-to-preview animated gesture did nothing useful.
Replace it with a long-press action menu on scene tiles:
- Ukryj scenę → POST /scenes/{id}/hide
- Oznacz jako duplikat → enter selection mode; tapping another tile merges the
long-pressed scene INTO the tapped one (POST /scenes/{keep}/merge/{drop}).
SceneActionsProvider holds the selection state + a bottom banner, so it works across
all 5 scene-list screens via the shared SceneTile (no per-screen wiring). Selecting
mode highlights tappable tiles and badges the pending duplicate. Animated thumbnails
kept only as a still-fallback image; has_animated_thumbnail filter removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e1c7efb947
commit
904f8984c8
5 changed files with 242 additions and 28 deletions
|
|
@ -23,6 +23,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { GoonClient } from './src/api';
|
import { GoonClient } from './src/api';
|
||||||
import { ClientProvider } from './src/ClientContext';
|
import { ClientProvider } from './src/ClientContext';
|
||||||
|
import { SceneActionsProvider } from './src/SceneActionsContext';
|
||||||
import { ErrorBoundary } from './src/ErrorBoundary';
|
import { ErrorBoundary } from './src/ErrorBoundary';
|
||||||
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
||||||
import { APP_VERSION } from './src/lib/appVersion';
|
import { APP_VERSION } from './src/lib/appVersion';
|
||||||
|
|
@ -335,15 +336,17 @@ export default function App() {
|
||||||
) : null}
|
) : null}
|
||||||
{client ? (
|
{client ? (
|
||||||
<ClientProvider client={client}>
|
<ClientProvider client={client}>
|
||||||
<AppNavigator
|
<SceneActionsProvider>
|
||||||
client={client}
|
<AppNavigator
|
||||||
appVersion={APP_VERSION}
|
client={client}
|
||||||
onLogout={async () => {
|
appVersion={APP_VERSION}
|
||||||
await clearCredentials();
|
onLogout={async () => {
|
||||||
setClient(null);
|
await clearCredentials();
|
||||||
queryClient.clear();
|
setClient(null);
|
||||||
}}
|
queryClient.clear();
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</SceneActionsProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen
|
<LoginScreen
|
||||||
|
|
|
||||||
171
mobile/src/SceneActionsContext.tsx
Normal file
171
mobile/src/SceneActionsContext.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* SceneActions — long-press akcje na kafelkach scen (bug-report 5a6844db).
|
||||||
|
* Zastępuje martwy animated-preview gesture. Long-press → menu:
|
||||||
|
* - Ukryj scenę → POST /scenes/{id}/hide (playback dead, znika z list)
|
||||||
|
* - Oznacz duplikat → wejście w tryb wyboru; następny tap na inną scenę scala
|
||||||
|
* (merge długo-naciśniętej W wybraną). Banner na dole prowadzi przez wybór.
|
||||||
|
*
|
||||||
|
* Context bo SceneTile jest współdzielony przez 5 ekranów — jeden provider daje
|
||||||
|
* tryb selekcji działający wszędzie bez per-screen wiringu.
|
||||||
|
*/
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useClient } from './ClientContext';
|
||||||
|
import { theme } from './theme';
|
||||||
|
import type { SceneOut } from './types';
|
||||||
|
|
||||||
|
interface SceneActionsCtx {
|
||||||
|
pendingDuplicate: SceneOut | null;
|
||||||
|
isSelecting: boolean;
|
||||||
|
/** Otwórz menu akcji dla sceny (long-press). */
|
||||||
|
openActions: (scene: SceneOut) => void;
|
||||||
|
/** W trybie selekcji: wybierz tę scenę jako oryginał i scal w nią duplikat. */
|
||||||
|
pickDuplicateTarget: (target: SceneOut) => void;
|
||||||
|
cancelDuplicate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = React.createContext<SceneActionsCtx | null>(null);
|
||||||
|
|
||||||
|
export function useSceneActions(): SceneActionsCtx {
|
||||||
|
const c = React.useContext(Ctx);
|
||||||
|
if (!c) throw new Error('useSceneActions used outside SceneActionsProvider');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENE_LIST_KEYS = ['scenes', 'performer-scenes', 'studio-scenes', 'tag-scenes', 'site-scenes'];
|
||||||
|
|
||||||
|
export function SceneActionsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [pendingDuplicate, setPending] = React.useState<SceneOut | null>(null);
|
||||||
|
|
||||||
|
const invalidate = React.useCallback(() => {
|
||||||
|
for (const k of SCENE_LIST_KEYS) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [k] });
|
||||||
|
}
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const hide = React.useCallback(
|
||||||
|
(scene: SceneOut) => {
|
||||||
|
Alert.alert('Ukryć scenę?', scene.title, [
|
||||||
|
{ text: 'Anuluj', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Ukryj',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await client.hideScene(scene.id);
|
||||||
|
invalidate();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Nie udało się ukryć', e?.message || 'unknown error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[client, invalidate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openActions = React.useCallback(
|
||||||
|
(scene: SceneOut) => {
|
||||||
|
if (pendingDuplicate) return; // w trakcie wyboru duplikatu — ignoruj
|
||||||
|
Alert.alert(scene.title, 'Akcje sceny', [
|
||||||
|
{ text: 'Ukryj scenę', style: 'destructive', onPress: () => hide(scene) },
|
||||||
|
{ text: 'Oznacz jako duplikat', onPress: () => setPending(scene) },
|
||||||
|
{ text: 'Anuluj', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[pendingDuplicate, hide],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickDuplicateTarget = React.useCallback(
|
||||||
|
(target: SceneOut) => {
|
||||||
|
const dup = pendingDuplicate;
|
||||||
|
if (!dup || target.id === dup.id) {
|
||||||
|
setPending(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
'Scalić duplikat?',
|
||||||
|
`„${dup.title}"\n\nscalić w\n\n„${target.title}"`,
|
||||||
|
[
|
||||||
|
{ text: 'Anuluj', style: 'cancel', onPress: () => setPending(null) },
|
||||||
|
{
|
||||||
|
text: 'Scal',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
// keep = wybrany oryginał (target), drop = długo-naciśnięty duplikat (dup)
|
||||||
|
await client.mergeDuplicateScene(target.id, dup.id);
|
||||||
|
invalidate();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Scalanie nie powiodło się', e?.message || 'unknown error');
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pendingDuplicate, client, invalidate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDuplicate = React.useCallback(() => setPending(null), []);
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
pendingDuplicate,
|
||||||
|
isSelecting: !!pendingDuplicate,
|
||||||
|
openActions,
|
||||||
|
pickDuplicateTarget,
|
||||||
|
cancelDuplicate,
|
||||||
|
}),
|
||||||
|
[pendingDuplicate, openActions, pickDuplicateTarget, cancelDuplicate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{pendingDuplicate ? (
|
||||||
|
<View style={styles.banner} pointerEvents="box-none">
|
||||||
|
<View style={styles.bannerInner}>
|
||||||
|
<Text style={styles.bannerText} numberOfLines={2}>
|
||||||
|
Wybierz oryginał — dotknij scenę, w którą scalić „{pendingDuplicate.title}"
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={cancelDuplicate} style={styles.bannerCancel} hitSlop={8}>
|
||||||
|
<Text style={styles.bannerCancelText}>Anuluj</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
bannerInner: {
|
||||||
|
backgroundColor: theme.accent,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
bannerText: { color: theme.fg, fontSize: 13, fontWeight: '600', flex: 1 },
|
||||||
|
bannerCancel: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
bannerCancelText: { color: theme.fg, fontWeight: '700', fontSize: 13 },
|
||||||
|
});
|
||||||
|
|
@ -113,8 +113,6 @@ export class GoonClient {
|
||||||
qs.set('performer_ids', params.performer_ids.join(','));
|
qs.set('performer_ids', params.performer_ids.join(','));
|
||||||
}
|
}
|
||||||
if (params.has_playback !== undefined) qs.set('has_playback', String(params.has_playback));
|
if (params.has_playback !== undefined) qs.set('has_playback', String(params.has_playback));
|
||||||
if (params.has_animated_thumbnail !== undefined)
|
|
||||||
qs.set('has_animated_thumbnail', String(params.has_animated_thumbnail));
|
|
||||||
// Default: filtrujemy sceny <60s — bug-report 2026-05-23 (40cd28aa):
|
// Default: filtrujemy sceny <60s — bug-report 2026-05-23 (40cd28aa):
|
||||||
// "Takie sceny po 1 min to można wywalić". Pornapp/freshporno czasem
|
// "Takie sceny po 1 min to można wywalić". Pornapp/freshporno czasem
|
||||||
// zassuje teasery/trailery 30-50s, które są bezużyteczne na listach.
|
// zassuje teasery/trailery 30-50s, które są bezużyteczne na listach.
|
||||||
|
|
@ -241,6 +239,16 @@ export class GoonClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long-press tile akcje. hideScene → wszystkie playback dead (scena znika z list).
|
||||||
|
async hideScene(sceneId: string): Promise<{ scene_id: string; playback_marked_dead: number }> {
|
||||||
|
return this.request(`/scenes/${sceneId}/hide`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scal `dropId` w `keepId` (mark-duplicate flow: long-press dup → wybór oryginału).
|
||||||
|
async mergeDuplicateScene(keepId: string, dropId: string): Promise<{ keep_id: string; dropped_id: string }> {
|
||||||
|
return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
|
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
|
||||||
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
|
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@
|
||||||
*/
|
*/
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import * as Haptics from 'expo-haptics';
|
import React from 'react';
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSceneActions } from '../SceneActionsContext';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
import type { SceneOut } from '../types';
|
import type { SceneOut } from '../types';
|
||||||
|
|
@ -52,21 +52,31 @@ interface Props {
|
||||||
export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
||||||
const navigation =
|
const navigation =
|
||||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions();
|
||||||
|
|
||||||
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
|
// animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej
|
||||||
?.animated_thumbnail_url;
|
// (preview-on-hold usunięty — bug-report 5a6844db, gest nic nie robił).
|
||||||
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
|
const displayUrl =
|
||||||
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
|
scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ??
|
||||||
|
scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url;
|
||||||
|
|
||||||
const startPreview = () => {
|
const isPending = pendingDuplicate?.id === scene.id;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
// Tryb wyboru duplikatu: tap = wybierz oryginał (chyba że to ta sama kafelka).
|
||||||
|
if (isSelecting && !isPending) {
|
||||||
|
pickDuplicateTarget(scene);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.navigate('SceneDetail', { id: scene.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLongPress = () => {
|
||||||
if (onLongPress) {
|
if (onLongPress) {
|
||||||
onLongPress();
|
onLongPress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!animatedUrl) return;
|
openActions(scene);
|
||||||
setIsPreviewing(true);
|
|
||||||
Haptics.selectionAsync().catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dim = scene.finished === true;
|
const dim = scene.finished === true;
|
||||||
|
|
@ -94,13 +104,24 @@ export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.tile}
|
style={styles.tile}
|
||||||
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
|
onPress={handlePress}
|
||||||
onLongPress={startPreview}
|
onLongPress={handleLongPress}
|
||||||
onPressOut={() => setIsPreviewing(false)}
|
delayLongPress={300}
|
||||||
delayLongPress={180}
|
|
||||||
>
|
>
|
||||||
<View style={[styles.thumbWrap, dim && styles.thumbDim]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.thumbWrap,
|
||||||
|
dim && styles.thumbDim,
|
||||||
|
isSelecting && !isPending && styles.thumbSelectable,
|
||||||
|
isPending && styles.thumbPending,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Thumb url={displayUrl} style={styles.thumb} />
|
<Thumb url={displayUrl} style={styles.thumb} />
|
||||||
|
{isPending ? (
|
||||||
|
<View style={styles.dupBadge}>
|
||||||
|
<Text style={styles.dupBadgeText}>DUPLIKAT</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
{scene.is_favorite ? (
|
{scene.is_favorite ? (
|
||||||
<View style={styles.favBadge}>
|
<View style={styles.favBadge}>
|
||||||
<Text style={styles.favBadgeText}>★</Text>
|
<Text style={styles.favBadgeText}>★</Text>
|
||||||
|
|
@ -146,6 +167,18 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
thumb: { width: '100%', height: '100%' },
|
thumb: { width: '100%', height: '100%' },
|
||||||
thumbDim: { opacity: 0.45 },
|
thumbDim: { opacity: 0.45 },
|
||||||
|
thumbSelectable: { borderWidth: 2, borderColor: theme.accent, opacity: 0.85 },
|
||||||
|
thumbPending: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
|
||||||
|
dupBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
left: 6,
|
||||||
|
backgroundColor: theme.accent,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
|
||||||
favBadge: {
|
favBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 6,
|
top: 6,
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,6 @@ export interface ScenesListParams {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
performer_ids?: string[];
|
performer_ids?: string[];
|
||||||
has_playback?: boolean;
|
has_playback?: boolean;
|
||||||
has_animated_thumbnail?: boolean;
|
|
||||||
min_duration_sec?: number;
|
min_duration_sec?: number;
|
||||||
max_duration_sec?: number;
|
max_duration_sec?: number;
|
||||||
released_within_days?: number;
|
released_within_days?: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue