goon/mobile/src/screens/BlacklistScreen.tsx
goon-foss 017927793a style(mobile): myslniki em/en poza tekstow widocznych dla uzytkownika
32 miejsca w 14 plikach: changelog, onboarding, ekrany Sites/Blacklist/Login/Player,
komunikaty bledow, placeholdery. Zamiast myslnika przecinek, dwukropek, kropka albo
nawias, zaleznie od tego co dane zdanie robi.

Zostawione swiadomie: pojedynczy glif U+2014 jako znacznik BRAKU WARTOSCI
(release_year ?? '—', nieocenione osie w SitesScreen, separator zakresu w filtrze
filmow). To element interfejsu, nie proza, a zwykly dywiz wyglada tam gorzej.
Zostawione tez komentarze w kodzie, w tym te wewnatrz INJECTED_JS.

Przy okazji: MigrationBanner dostal twardy warunek na runtime (needsManualMigration).
Wczesniej przed pokazaniem go nowej wersji chronilo TYLKO to, ze kanal OTA 1.2 byl
pusty. Pierwsza publikacja na 1.2 wyslalaby ten sam bundle i baner kazalby migrowac
z wersji, na ktora user wlasnie przeszedl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:29:21 +02:00

222 lines
6.8 KiB
TypeScript

// Zarządzanie blacklistą treści — ukryte tagi / performerzy / studia. Sceny z
// blacklisted entity wypadają z każdego /scenes (backend auto-apply, app/api/blacklist.py).
//
// Powód (user-report 86a9ec72: "remove all gay scenes from randomly popping up"): nie było
// żadnego UI do (a) ukrycia tagu, (b) podejrzenia/cofnięcia blacklisty — mimo że Alert przy
// "Hide performer" obiecywał "undo from Settings → Blacklist". Ten ekran domyka oba.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import { theme } from '../theme';
import type { BlacklistEntry, BlacklistKind, TagCount } from '../types';
export function BlacklistScreen() {
const client = useClient();
const queryClient = useQueryClient();
const [tagQ, setTagQ] = useState('');
const blacklistQuery = useQuery({
queryKey: ['blacklist'],
queryFn: () => client.listBlacklist(),
});
const tagSearch = useQuery({
queryKey: ['blacklist-tag-search', tagQ],
queryFn: () => client.listTags({ q: tagQ || undefined, per_page: 40 }),
enabled: tagQ.trim().length > 0,
});
const afterChange = () => {
queryClient.invalidateQueries({ queryKey: ['blacklist'] });
// Listy scen filtrują po blackliście — odśwież leniwie (bez wymuszania refetcha).
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
};
const addTag = useMutation({
mutationFn: (id: string) => client.addBlacklist('tag', id),
onSuccess: afterChange,
});
const remove = useMutation({
mutationFn: ({ kind, id }: { kind: BlacklistKind; id: string }) =>
client.removeBlacklist(kind, id),
onSuccess: afterChange,
});
const bl = blacklistQuery.data;
const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id));
const renderRow = (kind: BlacklistKind, e: BlacklistEntry) => (
<View key={`${kind}-${e.id}`} style={styles.row}>
<Text style={styles.rowName} numberOfLines={1}>
{e.name}
</Text>
<Pressable
hitSlop={10}
onPress={() => remove.mutate({ kind, id: e.id })}
style={styles.removeBtn}
>
<Text style={styles.removeText}>Unhide</Text>
</Pressable>
</View>
);
return (
<FlatList
style={styles.root}
data={[1]}
keyExtractor={() => 'x'}
renderItem={() => (
<View style={{ paddingBottom: 40 }}>
<Text style={styles.intro}>
Scenes tagged with, or from, anything you hide here stop showing up anywhere in
the app. Add a tag below (e.g. a category you never want to see).
</Text>
<Text style={styles.sectionTitle}>Hide a tag</Text>
<TextInput
style={styles.search}
value={tagQ}
onChangeText={setTagQ}
placeholder="search tags to hide…"
placeholderTextColor={theme.muted}
autoCapitalize="none"
autoCorrect={false}
/>
{tagQ.trim().length > 0 ? (
tagSearch.isLoading ? (
<ActivityIndicator color={theme.fg} style={{ margin: 12 }} />
) : (
<View style={styles.chips}>
{(tagSearch.data?.items ?? []).map((tag: TagCount) => {
const already = blacklistedTagIds.has(tag.id);
return (
<Pressable
key={tag.id}
disabled={already || addTag.isPending}
style={[styles.chip, already && styles.chipDisabled]}
onPress={() => addTag.mutate(tag.id)}
>
<Text style={[styles.chipText, already && styles.chipTextDisabled]}>
{already ? '✓ ' : '+ '}
{tag.name}
</Text>
</Pressable>
);
})}
</View>
)
) : null}
{blacklistQuery.isLoading ? (
<ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />
) : (
<>
<Section title="Hidden tags" count={bl?.tags.length ?? 0}>
{(bl?.tags ?? []).map((e) => renderRow('tag', e))}
</Section>
<Section title="Hidden performers" count={bl?.performers.length ?? 0}>
{(bl?.performers ?? []).map((e) => renderRow('performer', e))}
</Section>
<Section title="Hidden studios" count={bl?.studios.length ?? 0}>
{(bl?.studios ?? []).map((e) => renderRow('studio', e))}
</Section>
</>
)}
</View>
)}
/>
);
}
function Section({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{title} {count > 0 ? `(${count})` : ''}
</Text>
{count === 0 ? <Text style={styles.empty}>nothing hidden</Text> : children}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
intro: {
color: theme.muted,
fontSize: 13,
lineHeight: 19,
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 4,
},
section: {
paddingVertical: 12,
borderTopColor: theme.border,
borderTopWidth: 1,
marginTop: 12,
},
sectionTitle: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 10,
paddingHorizontal: 16,
},
search: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
color: theme.fg,
padding: 10,
marginHorizontal: 16,
},
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, padding: 16 },
chip: {
backgroundColor: theme.card,
borderColor: theme.accent,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 5,
},
chipDisabled: { borderColor: theme.border, opacity: 0.6 },
chipText: { color: theme.accent, fontSize: 13 },
chipTextDisabled: { color: theme.muted },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 16,
},
rowName: { color: theme.fg, fontSize: 15, flex: 1, marginRight: 12 },
removeBtn: {
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 6,
},
removeText: { color: theme.accent, fontSize: 13, fontWeight: '600' },
empty: { color: theme.muted, fontSize: 13, paddingHorizontal: 16 },
});