feat(performers): pelny filtr scen na ekranie performera
Prosba 39314780: "Add similar filters to when we search for videos by pornstars". Ekran performera dostaje TEN SAM modal co glowny feed (ScenesFilterModal), nie osobny: jakosc, minimalna dlugosc, sortowanie, zrodlo/hoster, tagi, studia oraz "Also featuring" (sceny takze z inna osoba). Chipsy top-tagow zostaja OBOK, jako jednodotykowy skrot na najczestszy przypadek; ich wybor scala sie z tagami z modala. Trzy roznice wzgledem glownego feedu, wszystkie zamierzone: * domyslne sortowanie po dacie premiery (tak ten ekran dzialal od zawsze), * prog dlugosci domyslnie 0 (wejscie w konkretnego performera nie ma ukrywac scen o nieznanej dlugosci, bo NULL nie przechodzi `>= 60`; wybor "5+ min" podnosi go jawnie), * "Also featuring" zamiast "Performers": backend laczy performer_ids koniunkcja, wiec wybrani sa dokladani do biezacego performera przez AND, nie zastepuja go. Modal dostal na to prop `performersLabel`. Licznik na przycisku liczy sie wzgledem domyslnych TEGO ekranu, inaczej has_playback=true podbijaloby "Filter 1" na czysto. Backend bez zmian: /scenes juz przyjmuje wszystkie te parametry.
This commit is contained in:
parent
ccba96376c
commit
c0a5d02ece
3 changed files with 74 additions and 8 deletions
|
|
@ -29,6 +29,13 @@ export type ChangelogEntry = {
|
|||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
id: '2026-08-26b',
|
||||
date: 'August 2026',
|
||||
items: [
|
||||
'A performer page now has the same Filter as the main feed: quality, minimum length, sort order, source, tags, studios, and scenes that also feature another performer. The quick tag chips are still there for a one-tap filter.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2026-08-26a',
|
||||
date: 'August 2026',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { useClient } from '../ClientContext';
|
||||
import { MoviePosterCard } from '../components/MoviePosterCard';
|
||||
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
||||
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
|
||||
import { sortNewFirst } from '../lib/newScenes';
|
||||
import { usePreferences } from '../PreferencesContext';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
|
|
@ -32,6 +33,11 @@ type Tab = 'scenes' | 'movies';
|
|||
|
||||
const MOVIE_COLS = 2;
|
||||
|
||||
// Ten sam modal i stan filtra co główny feed (prośba 39314780: "similar filters to
|
||||
// when we search for videos by pornstars"), z jedną różnicą: domyślne sortowanie po
|
||||
// dacie premiery, bo tak ten ekran sortował od zawsze i taka jest intuicja "filmografii".
|
||||
const PERFORMER_DEFAULT_FILTER: FilterState = { ...DEFAULT_FILTER, sort: 'release_date' };
|
||||
|
||||
export function PerformerScenesScreen() {
|
||||
const client = useClient();
|
||||
const { gridColumns } = usePreferences();
|
||||
|
|
@ -155,23 +161,51 @@ export function PerformerScenesScreen() {
|
|||
|
||||
// Filtr tagu IN-PLACE (bug-report 0264a3ff: wybór tagu ma filtrować sceny tej
|
||||
// aktorki w tym widoku, nie nawigować do osobnego ekranu). null = bez filtra.
|
||||
// Chipsy zostają OBOK pełnego modala: to skrót jednodotykowy na najczęstszy
|
||||
// przypadek, a modal dokłada resztę (jakość, długość, studio, sort, obsada).
|
||||
const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null);
|
||||
|
||||
const [filter, setFilter] = React.useState<FilterState>(PERFORMER_DEFAULT_FILTER);
|
||||
const [filterOpen, setFilterOpen] = React.useState(false);
|
||||
// Licznik liczony WZGLĘDEM domyślnych tego ekranu, nie jak w głównym feedzie, gdzie
|
||||
// domyślne has_playback=true podbija licznik i "Filter 1" świeci na czysto.
|
||||
const filterActiveCount =
|
||||
filter.tagSlugs.length +
|
||||
filter.studioSlugs.length +
|
||||
filter.performerIds.length +
|
||||
(filter.origin.trim() ? 1 : 0) +
|
||||
(filter.minDurationSec > 0 ? 1 : 0) +
|
||||
(filter.minQualityP > 0 ? 1 : 0) +
|
||||
(filter.sort !== PERFORMER_DEFAULT_FILTER.sort ? 1 : 0) +
|
||||
(!filter.hasPlayback ? 1 : 0);
|
||||
|
||||
const scenesQuery = useQuery({
|
||||
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null],
|
||||
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null, filter],
|
||||
queryFn: () =>
|
||||
client.listScenes({
|
||||
performer_ids: [id],
|
||||
// Wybrani w modalu to KONIUNKCJA z bieżącym performerem ("sceny także z..."),
|
||||
// backend łączy performer_ids przez AND. Dubel id jest niegroźny, ale filtrujemy.
|
||||
performer_ids: [id, ...filter.performerIds.filter((p) => p !== id)],
|
||||
vertical: vertical ?? undefined,
|
||||
// 0 = nie nakładaj progu długości. User wszedł JAWNIE w tę pozycję, więc
|
||||
// ukrywanie scen o nieznanej długości (w SQL NULL nie przechodzi `>= 60`)
|
||||
// wyglądało jak pusty albo zatrzymany serwis. Patrz komentarz w api.ts.
|
||||
min_duration_sec: 0,
|
||||
tags: selectedTag ? [selectedTag.slug] : undefined,
|
||||
sort: 'release_date',
|
||||
// Wybór w filtrze ("5+ min" itd.) podnosi próg jawnie.
|
||||
min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : 0,
|
||||
tags: (() => {
|
||||
const merged = new Set(filter.tagSlugs);
|
||||
if (selectedTag) merged.add(selectedTag.slug);
|
||||
return merged.size ? [...merged] : undefined;
|
||||
})(),
|
||||
studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined,
|
||||
origin: filter.origin.trim() || undefined,
|
||||
min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined,
|
||||
include_stubs: filter.includeStubs || undefined,
|
||||
sort: filter.sort,
|
||||
per_page: 200,
|
||||
// Sceny TPDB/StashDB bez tube-linków nie są usable, zawsze filtrujemy.
|
||||
has_playback: true,
|
||||
// Domyślnie ukrywamy stuby TPDB/StashDB bez tube-linków; wyłączenie w modalu
|
||||
// pokazuje pełną filmografię, z pozycjami, których (jeszcze) nie da się odtworzyć.
|
||||
has_playback: filter.hasPlayback || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -285,6 +319,14 @@ export function PerformerScenesScreen() {
|
|||
<Text style={styles.actionBtnTextPrimary}>↻ Search more scenes</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.actionBtn, filterActiveCount > 0 && styles.actionBtnActive]}
|
||||
onPress={() => setFilterOpen(true)}
|
||||
>
|
||||
<Text style={styles.actionBtnTextPrimary}>
|
||||
Filter{filterActiveCount > 0 ? ` ${filterActiveCount}` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{tab === 'scenes' && !!tagsQuery.data?.items.length && (
|
||||
<ScrollView
|
||||
|
|
@ -383,6 +425,17 @@ export function PerformerScenesScreen() {
|
|||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ScenesFilterModal
|
||||
visible={filterOpen}
|
||||
initial={filter}
|
||||
vertical={vertical ?? undefined}
|
||||
performersLabel="Also featuring"
|
||||
onApply={(next) => {
|
||||
setFilter(next);
|
||||
setFilterOpen(false);
|
||||
}}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -441,6 +494,7 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBtnActive: { borderColor: theme.accent },
|
||||
actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 },
|
||||
actionBtnTextPrimary: { color: theme.accentText, fontWeight: '700', fontSize: 12 },
|
||||
tagRow: {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export function ScenesFilterModal({
|
|||
onApply,
|
||||
onClose,
|
||||
vertical,
|
||||
performersLabel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
initial: FilterState;
|
||||
|
|
@ -94,6 +95,10 @@ export function ScenesFilterModal({
|
|||
/** Wertykał ekranu ('animation'/'jav'); filtruje listę tagów do jego słownika.
|
||||
* Bez tego sekcja animacji dostawała zachodnie tagi, których tam nie ma czym wypełnić. */
|
||||
vertical?: string;
|
||||
/** Tytuł sekcji performerów. Ekran performera podaje "Also featuring", bo tam wybór
|
||||
* znaczy "sceny TAKŻE z tą osobą" (backend łączy performer_ids koniunkcją), a nie
|
||||
* wybór, czyje sceny oglądamy. */
|
||||
performersLabel?: string;
|
||||
}) {
|
||||
const client = useClient();
|
||||
const [draft, setDraft] = useState<FilterState>(initial);
|
||||
|
|
@ -362,7 +367,7 @@ export function ScenesFilterModal({
|
|||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={`Performers (${draft.performerIds.length} selected)`}>
|
||||
<Section title={`${performersLabel ?? 'Performers'} (${draft.performerIds.length} selected)`}>
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
value={performerQ}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue