diff --git a/mobile/src/SceneActionsContext.tsx b/mobile/src/SceneActionsContext.tsx index 36cbcf9..d1025a9 100644 --- a/mobile/src/SceneActionsContext.tsx +++ b/mobile/src/SceneActionsContext.tsx @@ -1,28 +1,42 @@ /** * 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. + * - 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 { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +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 { - pendingDuplicate: SceneOut | null; + /** 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 selekcji: wybierz tę scenę jako oryginał i scal w nią duplikat. */ - pickDuplicateTarget: (target: SceneOut) => void; + /** W trybie wyboru: dorzuć scenę do duplikatów albo ją odznacz. */ + toggleDuplicate: (scene: SceneOut) => void; cancelDuplicate: () => void; } @@ -39,7 +53,9 @@ const SCENE_LIST_KEYS = ['scenes', 'performer-scenes', 'studio-scenes', 'tag-sce export function SceneActionsProvider({ children }: { children: React.ReactNode }) { const client = useClient(); const queryClient = useQueryClient(); - const [pendingDuplicate, setPending] = React.useState(null); + 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) { @@ -47,6 +63,11 @@ export function SceneActionsProvider({ children }: { children: React.ReactNode } } }, [queryClient]); + const reset = React.useCallback(() => { + setKeeper(null); + setMarked([]); + }, []); + const hide = React.useCallback( (scene: SceneOut) => { Alert.alert('Hide scene?', scene.title, [ @@ -70,71 +91,109 @@ export function SceneActionsProvider({ children }: { children: React.ReactNode } const openActions = React.useCallback( (scene: SceneOut) => { - if (pendingDuplicate) return; // w trakcie wyboru duplikatu, ignoruj + if (keeper) return; // w trakcie wyboru duplikatów, ignoruj Alert.alert(scene.title, 'Scene actions', [ { text: 'Hide scene', style: 'destructive', onPress: () => hide(scene) }, - { text: 'Mark as duplicate', onPress: () => setPending(scene) }, + { text: 'Merge duplicates into this', onPress: () => setKeeper(scene) }, { text: 'Cancel', style: 'cancel' }, ]); }, - [pendingDuplicate, hide], + [keeper, hide], ); - const pickDuplicateTarget = React.useCallback( - (target: SceneOut) => { - const dup = pendingDuplicate; - if (!dup || target.id === dup.id) { - setPending(null); - return; - } - Alert.alert( - 'Merge duplicate?', - `"${dup.title}"\n\nmerge into\n\n"${target.title}"`, - [ - { text: 'Cancel', style: 'cancel', onPress: () => setPending(null) }, - { - text: 'Merge', - 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('Merge failed', e?.message || 'unknown error'); - } finally { - setPending(null); - } - }, - }, - ], + 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], ); }, - [pendingDuplicate, client, invalidate], + [keeper, merging], ); - const cancelDuplicate = React.useCallback(() => setPending(null), []); + 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( () => ({ - pendingDuplicate, - isSelecting: !!pendingDuplicate, + keeper, + isSelecting: !!keeper, + isMarked, openActions, - pickDuplicateTarget, - cancelDuplicate, + toggleDuplicate, + cancelDuplicate: reset, }), - [pendingDuplicate, openActions, pickDuplicateTarget, cancelDuplicate], + [keeper, isMarked, openActions, toggleDuplicate, reset], ); return ( {children} - {pendingDuplicate ? ( + {keeper ? ( - Pick the original, tap the scene to merge "{pendingDuplicate.title}" into + {marked.length === 0 + ? `Tap every duplicate of "${keeper.title}"` + : `${marked.length} marked, keeping "${keeper.title}"`} - + {merging ? ( + + ) : ( + + + {marked.length > 0 ? `Merge ${marked.length}` : 'Merge'} + + + )} + Cancel @@ -161,6 +220,14 @@ const styles = StyleSheet.create({ 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, diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 31cbcac..593bbd0 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-08-23a', + date: 'August 2026', + items: [ + 'You can now merge more than two copies of the same scene at once. Long-press the copy you want to keep and pick "Merge duplicates into this", then tap every other copy, and merge them all in one go. Previously you had to repeat the whole thing for each pair.', + ], + }, { id: '2026-08-22f', date: 'August 2026', diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx index 298fd7e..e34b160 100644 --- a/mobile/src/components/SceneTile.tsx +++ b/mobile/src/components/SceneTile.tsx @@ -90,7 +90,7 @@ function titleWithoutStudio(title: string, studio?: string | null): string { function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) { const navigation = useNavigation>(); - const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions(); + const { isSelecting, keeper, isMarked, openActions, toggleDuplicate } = useSceneActions(); // animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej // (preview-on-hold usunięty, bug-report 5a6844db, gest nic nie robił). @@ -98,12 +98,14 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ?? scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url; - const isPending = pendingDuplicate?.id === scene.id; + const isKeeper = keeper?.id === scene.id; + const marked = isMarked(scene.id); const handlePress = () => { - // Tryb wyboru duplikatu: tap = wybierz oryginał (chyba że to ta sama kafelka). - if (isSelecting && !isPending) { - pickDuplicateTarget(scene); + // Tryb wyboru duplikatów: tap zaznacza i odznacza. Kafelek keepera nie reaguje, + // bo scena nie może być duplikatem samej siebie. + if (isSelecting && !isKeeper) { + toggleDuplicate(scene); return; } navigation.navigate('SceneDetail', { id: scene.id }); @@ -153,14 +155,17 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: style={[ styles.thumbWrap, dim && styles.thumbDim, - isSelecting && !isPending && styles.thumbSelectable, - isPending && styles.thumbPending, + isSelecting && !isKeeper && !marked && styles.thumbSelectable, + marked && styles.thumbMarked, + isKeeper && styles.thumbKeeper, ]} > - {isPending ? ( - - DUPLIKAT + {isKeeper || marked ? ( + + + {isKeeper ? 'KEEP' : 'DUPLICATE'} + ) : null} {scene.is_favorite ? ( @@ -245,7 +250,10 @@ const styles = StyleSheet.create({ thumb: { width: '100%', height: '100%' }, thumbDim: { opacity: 0.45 }, thumbSelectable: { borderWidth: 2, borderColor: theme.accentText, opacity: 0.85 }, - thumbPending: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 }, + // Zaznaczony duplikat gaśnie, bo za chwilę zniknie. Keeper zostaje jasny i dostaje + // grubszą ramkę, żeby przy kilku zaznaczeniach było widać, w co się scala. + thumbMarked: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 }, + thumbKeeper: { borderWidth: 3, borderColor: theme.accent, opacity: 1 }, dupBadge: { position: 'absolute', top: 6, @@ -256,6 +264,8 @@ const styles = StyleSheet.create({ borderRadius: 4, }, dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 }, + keepBadge: { backgroundColor: theme.fg }, + keepBadgeText: { color: theme.accent }, favBadge: { position: 'absolute', top: 6,