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>
222 lines
6.8 KiB
TypeScript
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 },
|
|
});
|