feat(mobile): movies — performer filter + 3-column grid

Two Movies-list reports. (1) 1044cd34 'do movies have a metadata base for performers/categories/studio/year': yes — 90% have year, 92% studio, 81% performers, 93% tags, and the filter already covered studio/genre/year. Added the missing dimension: a performer search-and-select in MovieFiltersSheet (backend listMovies + api.ts already accepted performer_ids; only the UI was missing). (2) 0200956f 'use the space better': Movies grid goes 2 -> 3 columns (poster card is flex:1, scales fine) so ~50% more films per screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-21 23:21:22 +02:00
parent 960bc75be4
commit 4afebacad8
3 changed files with 95 additions and 2 deletions

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-21b',
date: 'June 2026',
items: [
'Filter movies by performer (Movies → Filter → Performers).',
'Movies grid fits more per screen (3 columns).',
],
},
{ {
id: '2026-06-21', id: '2026-06-21',
date: 'June 2026', date: 'June 2026',

View file

@ -34,6 +34,7 @@ export interface MovieFilters {
has_playback: boolean; has_playback: boolean;
tag_slugs: string[]; tag_slugs: string[];
studio_slugs: string[]; studio_slugs: string[];
performer_ids: string[];
} }
export const DEFAULT_MOVIE_FILTERS: MovieFilters = { export const DEFAULT_MOVIE_FILTERS: MovieFilters = {
@ -43,6 +44,7 @@ export const DEFAULT_MOVIE_FILTERS: MovieFilters = {
has_playback: false, has_playback: false,
tag_slugs: [], tag_slugs: [],
studio_slugs: [], studio_slugs: [],
performer_ids: [],
}; };
const SORT_OPTIONS: { value: MoviesSort; label: string }[] = [ const SORT_OPTIONS: { value: MoviesSort; label: string }[] = [
@ -126,6 +128,35 @@ export function MovieFiltersSheet({ visible, value, onChange, onClose }: Props)
})); }));
}; };
// Performers — long-tail, więc search-by-q (nie top-N jak studia/genres). `pnames`
// zapamiętuje id→imię z wyników, żeby zaznaczone chipy pokazywały nazwę.
const [perfQ, setPerfQ] = useState('');
const [pnames, setPnames] = useState<Record<string, string>>({});
const performersQuery = useQuery({
queryKey: ['movie-perf-search', perfQ],
queryFn: () => client.listPerformers({ q: perfQ, order: 'scene_count', per_page: 30 }),
enabled: visible && perfQ.trim().length > 0,
});
React.useEffect(() => {
const items = performersQuery.data?.items;
if (!items?.length) return;
setPnames((m) => {
const next = { ...m };
for (const p of items) next[p.id] = p.canonical_name;
return next;
});
}, [performersQuery.data]);
const togglePerformer = (id: string, name: string) => {
setPnames((m) => ({ ...m, [id]: name }));
setDraft((d) => ({
...d,
performer_ids: d.performer_ids.includes(id)
? d.performer_ids.filter((x) => x !== id)
: [...d.performer_ids, id],
}));
};
const apply = () => { const apply = () => {
const yf = parseInt(yearFromText, 10); const yf = parseInt(yearFromText, 10);
const yt = parseInt(yearToText, 10); const yt = parseInt(yearToText, 10);
@ -263,6 +294,56 @@ export function MovieFiltersSheet({ visible, value, onChange, onClose }: Props)
<Text style={styles.empty}>no studios yet</Text> <Text style={styles.empty}>no studios yet</Text>
)} )}
<Text style={styles.label}>
Performers {draft.performer_ids.length > 0 ? `(${draft.performer_ids.length})` : ''}
</Text>
{draft.performer_ids.length > 0 ? (
<View style={styles.chipRow}>
{draft.performer_ids.map((id) => (
<TouchableOpacity
key={id}
style={[styles.chip, styles.chipActive]}
onPress={() => togglePerformer(id, pnames[id] || '')}
>
<Text style={[styles.chipText, styles.chipTextActive]}>
{pnames[id] || 'performer'}
</Text>
</TouchableOpacity>
))}
</View>
) : null}
<TextInput
style={[styles.input, { marginTop: 8 }]}
value={perfQ}
onChangeText={setPerfQ}
placeholder="search performers…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
autoCorrect={false}
/>
{perfQ.trim().length > 0 ? (
performersQuery.isLoading ? (
<ActivityIndicator color={theme.muted} style={{ marginVertical: 8 }} />
) : (
<View style={[styles.chipRow, { marginTop: 8 }]}>
{(performersQuery.data?.items ?? [])
.filter((p) => !draft.performer_ids.includes(p.id))
.map((p) => (
<TouchableOpacity
key={p.id}
style={styles.chip}
onPress={() => togglePerformer(p.id, p.canonical_name)}
>
<Text style={styles.chipText}>
{p.canonical_name}
{p.scene_count > 0 ? ` · ${p.scene_count}` : ''}
</Text>
</TouchableOpacity>
))}
</View>
)
) : null}
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Only with playback</Text> <Text style={styles.toggleLabel}>Only with playback</Text>
<Switch <Switch
@ -301,7 +382,8 @@ export function isDefaultFilters(f: MovieFilters): boolean {
f.year_to === null && f.year_to === null &&
!f.has_playback && !f.has_playback &&
f.tag_slugs.length === 0 && f.tag_slugs.length === 0 &&
f.studio_slugs.length === 0 f.studio_slugs.length === 0 &&
f.performer_ids.length === 0
); );
} }

View file

@ -27,7 +27,9 @@ import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
const PER_PAGE = 30; const PER_PAGE = 30;
const NUM_COLS = 2; // 3 kolumny — plakaty 2:3 mieszczą się dobrze i widać 50% więcej filmów na ekran
// (user-report 0200956f: lepiej wykorzystać przestrzeń). Karta ma flex:1, skaluje się.
const NUM_COLS = 3;
export function MoviesScreen() { export function MoviesScreen() {
const client = useClient(); const client = useClient();
@ -86,6 +88,7 @@ export function MoviesScreen() {
has_playback: filters.has_playback || undefined, has_playback: filters.has_playback || undefined,
tags: filters.tag_slugs.length ? filters.tag_slugs : undefined, tags: filters.tag_slugs.length ? filters.tag_slugs : undefined,
studio_slugs: filters.studio_slugs.length ? filters.studio_slugs : undefined, studio_slugs: filters.studio_slugs.length ? filters.studio_slugs : undefined,
performer_ids: filters.performer_ids.length ? filters.performer_ids : undefined,
page: pageParam, page: pageParam,
per_page: PER_PAGE, per_page: PER_PAGE,
}), }),