bug-report c25e9b55: long-press scene actions were in Polish — translate menu,
banner and confirm dialogs to English. Thumb 'error' state (e.g. expired sxyprn
thumbnail 404) now shows the same 🎬 placeholder as 'empty' instead of a ⚠ broken
glyph (bug 2026-06-10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
/**
|
|
* 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('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 (pendingDuplicate) return; // w trakcie wyboru duplikatu — ignoruj
|
|
Alert.alert(scene.title, 'Scene actions', [
|
|
{ text: 'Hide scene', style: 'destructive', onPress: () => hide(scene) },
|
|
{ text: 'Mark as duplicate', onPress: () => setPending(scene) },
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
]);
|
|
},
|
|
[pendingDuplicate, 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);
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
},
|
|
[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}>
|
|
Pick the original — tap the scene to merge "{pendingDuplicate.title}" into
|
|
</Text>
|
|
<Pressable onPress={cancelDuplicate} style={styles.bannerCancel} hitSlop={8}>
|
|
<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 },
|
|
bannerCancel: {
|
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
|
borderRadius: 8,
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 12,
|
|
},
|
|
bannerCancelText: { color: theme.fg, fontWeight: '700', fontSize: 13 },
|
|
});
|