Zgloszenie c7f5e59e: "przydalaby sie mozliwosc zaznaczania wiecej niz 2 video przy duplikatach". Przy pieciu kopiach tej samej sceny stary uklad wymagal czterech przebiegow, kazdy z osobnym dialogiem, i za kazdym razem trzeba bylo na nowo wskazac oryginal. Model jest teraz odwrocony: dlugie przytrzymanie wybiera scene, ktora ZOSTAJE (plakietka KEEP), a kolejne tapniecia zaznaczaja i odznaczaja jej duplikaty (plakietka DUPLICATE, kafelek gasnie). Banner liczy zaznaczone i scala wszystkie jednym przyciskiem. Dzieki temu uzytkownik swiadomie wybiera te kopie, ktorej tytul i miniaturka przetrwaja, zamiast trafiac w nia przypadkiem. Backend bez zmian, endpoint scala parami, wiec lecimy petla. Kazde scalenie to osobna transakcja, wiec czesciowa porazka nie cofa udanych i raportujemy ja wprost zamiast udawac sukces. Sprawdzone na emulatorze na opublikowanej paczce: pozycja w menu, plakietki KEEP i DUPLICATE, licznik 2 -> 1 -> 2 przy odznaczaniu, tresc potwierdzenia z lista scen. Samego scalania nie wykonalem, to sa prawdziwe sceny z produkcji.
238 lines
8 KiB
TypeScript
238 lines
8 KiB
TypeScript
/**
|
|
* 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<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 [keeper, setKeeper] = React.useState<SceneOut | null>(null);
|
|
const [marked, setMarked] = React.useState<SceneOut[]>([]);
|
|
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 (
|
|
<Ctx.Provider value={value}>
|
|
{children}
|
|
{keeper ? (
|
|
<View style={styles.banner} pointerEvents="box-none">
|
|
<View style={styles.bannerInner}>
|
|
<Text style={styles.bannerText} numberOfLines={2}>
|
|
{marked.length === 0
|
|
? `Tap every duplicate of "${keeper.title}"`
|
|
: `${marked.length} marked, keeping "${keeper.title}"`}
|
|
</Text>
|
|
{merging ? (
|
|
<ActivityIndicator color={theme.fg} />
|
|
) : (
|
|
<Pressable
|
|
onPress={runMerge}
|
|
disabled={marked.length === 0}
|
|
style={[styles.bannerMerge, marked.length === 0 && styles.bannerMergeOff]}
|
|
hitSlop={8}
|
|
>
|
|
<Text style={styles.bannerMergeText}>
|
|
{marked.length > 0 ? `Merge ${marked.length}` : 'Merge'}
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
<Pressable onPress={reset} style={styles.bannerCancel} hitSlop={8} disabled={merging}>
|
|
<Text style={styles.bannerCancelText}>Cancel</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 },
|
|
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 },
|
|
});
|