/** * SceneActions, long-press akcje na kafelkach scen (bug-report 5a6844db). * Zastępuje martwy animated-preview gesture. Long-press → menu: * - Hide scene → POST /scenes/{id}/hide (playback dead, znika z list) * - Merge duplicates → tryb wyboru: długo-naciśnięta scena ZOSTAJE, kolejne * tapnięcia zaznaczają jej duplikaty, przycisk w bannerze scala wszystkie naraz. * * Dlaczego długo-naciśnięta scena jest tą, która zostaje (do 2026-08-23 było * odwrotnie): zgłoszenie c7f5e59e prosiło o zaznaczanie więcej niż dwóch scen. * Przy pięciu duplikatach na ekranie performera stary układ wymagał czterech * przebiegów, każdy z osobnym dialogiem, i za każdym razem trzeba było na nowo * wskazać oryginał. Teraz trzymasz tę scenę, którą chcesz zachować, tapiesz * resztę i scalasz raz. * * Backend scala parami (`POST /scenes/{keep}/merge/{drop}`), więc lecimy pętlą. * Każde scalenie jest osobną transakcją, dlatego częściowa porażka nie cofa tych, * które przeszły, i raportujemy ją wprost zamiast udawać sukces. * * 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 { ActivityIndicator, Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { useClient } from './ClientContext'; import { theme } from './theme'; import type { SceneOut } from './types'; interface SceneActionsCtx { /** Scena, która przetrwa scalanie (ta długo-naciśnięta). Null = brak trybu wyboru. */ keeper: SceneOut | null; isSelecting: boolean; /** Czy scena jest zaznaczona jako duplikat keepera. */ isMarked: (sceneId: string) => boolean; /** Otwórz menu akcji dla sceny (long-press). */ openActions: (scene: SceneOut) => void; /** W trybie wyboru: dorzuć scenę do duplikatów albo ją odznacz. */ toggleDuplicate: (scene: SceneOut) => void; cancelDuplicate: () => void; } const Ctx = React.createContext(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 [keeper, setKeeper] = React.useState(null); const [marked, setMarked] = React.useState([]); const [merging, setMerging] = React.useState(false); const invalidate = React.useCallback(() => { for (const k of SCENE_LIST_KEYS) { queryClient.invalidateQueries({ queryKey: [k] }); } }, [queryClient]); const reset = React.useCallback(() => { setKeeper(null); setMarked([]); }, []); const hide = React.useCallback( (scene: SceneOut) => { Alert.alert('Hide scene?', scene.title, [ { text: 'Cancel', style: 'cancel' }, { text: 'Hide', style: 'destructive', onPress: async () => { try { await client.hideScene(scene.id); invalidate(); } catch (e: any) { Alert.alert('Failed to hide', e?.message || 'unknown error'); } }, }, ]); }, [client, invalidate], ); const openActions = React.useCallback( (scene: SceneOut) => { if (keeper) return; // w trakcie wyboru duplikatów, ignoruj Alert.alert(scene.title, 'Scene actions', [ { text: 'Hide scene', style: 'destructive', onPress: () => hide(scene) }, { text: 'Merge duplicates into this', onPress: () => setKeeper(scene) }, { text: 'Cancel', style: 'cancel' }, ]); }, [keeper, hide], ); const toggleDuplicate = React.useCallback( (scene: SceneOut) => { if (!keeper || merging || scene.id === keeper.id) return; setMarked((prev) => prev.some((s) => s.id === scene.id) ? prev.filter((s) => s.id !== scene.id) : [...prev, scene], ); }, [keeper, merging], ); const isMarked = React.useCallback( (sceneId: string) => marked.some((s) => s.id === sceneId), [marked], ); const runMerge = React.useCallback(() => { if (!keeper || marked.length === 0 || merging) return; // Tytuły tniemy do trzech: Alert na Androidzie scrolluje treść, ale przy // kilkunastu pozycjach przyciski uciekają poza ekran. const preview = marked.slice(0, 3).map((s) => `- ${s.title}`).join('\n'); const more = marked.length > 3 ? `\n- and ${marked.length - 3} more` : ''; Alert.alert( marked.length === 1 ? 'Merge duplicate?' : `Merge ${marked.length} duplicates?`, `Keeping:\n${keeper.title}\n\nMerging in and removing:\n${preview}${more}`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Merge', onPress: async () => { setMerging(true); const failed: string[] = []; for (const dup of marked) { try { await client.mergeDuplicateScene(keeper.id, dup.id); } catch (e: any) { failed.push(`${dup.title}: ${e?.message || 'unknown error'}`); } } setMerging(false); invalidate(); reset(); if (failed.length) { Alert.alert( `Merged ${marked.length - failed.length} of ${marked.length}`, failed.slice(0, 5).join('\n\n'), ); } }, }, ], ); }, [keeper, marked, merging, client, invalidate, reset]); const value = React.useMemo( () => ({ keeper, isSelecting: !!keeper, isMarked, openActions, toggleDuplicate, cancelDuplicate: reset, }), [keeper, isMarked, openActions, toggleDuplicate, reset], ); return ( {children} {keeper ? ( {marked.length === 0 ? `Tap every duplicate of "${keeper.title}"` : `${marked.length} marked, keeping "${keeper.title}"`} {merging ? ( ) : ( {marked.length > 0 ? `Merge ${marked.length}` : 'Merge'} )} Cancel ) : null} ); } 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 }, bannerMerge: { backgroundColor: theme.fg, borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12, }, bannerMergeOff: { opacity: 0.4 }, bannerMergeText: { color: theme.accent, fontWeight: '800', fontSize: 13 }, bannerCancel: { backgroundColor: 'rgba(0,0,0,0.3)', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12, }, bannerCancelText: { color: theme.fg, fontWeight: '700', fontSize: 13 }, });