fix(ui): jeden wspolny zestaw kontrolek naglowka zamiast kopii per ekran

Zgloszenie 2026-08-18 (cztery zrzuty obok siebie): "rozne odstepy miedzy zakladkami,
kazda zakladka wyglada inaczej, rozne wielkosci text fields, rozne obramowki, chyba
nawet inne czcionki".

Przyczyna strukturalna: kazdy ekran-lista mial WLASNA kopie stylow search/toolbar/
przycisku filtra. Zmierzony dryf: rog pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14,
odstep toolbara 12 vs 8, dwa promienie pigulek (8 i 12), padding kontenera 16/16 vs
16/12 vs 0. "Inne czcionki" = etykiety mialy fontWeight:'700' BEZ fontFamily, a RN nie
syntezuje pogrubienia dla fontu custom -> chudy Regular zamiast bold.

Nowy `src/ui.tsx`: SearchInput, PillButton, Toolbar + wspolne stale SCREEN_PAD_*.
Jedna wysokosc kontrolek w wierszu, jeden promien, jedna ramka, waga przez fontFamily
(nie fontWeight), stan aktywny robi kolor. Scenes/Movies/Sites przepiete na to, ich
lokalne duplikaty stylow usuniete (w tym osierocony SegButton w Sites). JAV i Anime
reuzywaja ScenesScreen, wiec dostaja to samo za darmo.

Zweryfikowane na emulatorze: cztery zakladki jeden przy drugim maja identyczne pole,
przycisk, pigulki i marginesy. 403 na liscie to build debug spoza allowlisty, nie UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-18 11:42:32 +02:00
parent 6d338219ea
commit 17e4ecaa8a
5 changed files with 181 additions and 199 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-18e',
date: 'August 2026',
items: [
'Consistent look across the Scenes, Movies, Sites, JAV and Anime tabs. The search box, filter button and the little pills were each styled slightly differently per tab (different corners, borders, font sizes, spacing); they now all come from one shared set, so the top of every tab lines up and matches.',
],
},
{ {
id: '2026-08-18d', id: '2026-08-18d',
date: 'August 2026', date: 'August 2026',

View file

@ -11,7 +11,6 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
@ -25,6 +24,7 @@ import { MoviePosterCard } from '../components/MoviePosterCard';
import { getLastSeen, markSeen } from '../lib/seenStore'; import { getLastSeen, markSeen } from '../lib/seenStore';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
const PER_PAGE = 30; const PER_PAGE = 30;
// 3 kolumny, plakaty 2:3 mieszczą się dobrze i widać 50% więcej filmów na ekran // 3 kolumny, plakaty 2:3 mieszczą się dobrze i widać 50% więcej filmów na ekran
@ -104,24 +104,20 @@ export function MoviesScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.toolbar}> <Toolbar>
<TextInput <SearchInput
style={[styles.search, { flex: 1 }]}
placeholder="search movies" placeholder="search movies"
placeholderTextColor={theme.muted}
value={q} value={q}
onChangeText={setQ} onChangeText={setQ}
autoCorrect={false} autoCorrect={false}
autoCapitalize="none"
returnKeyType="search" returnKeyType="search"
/> />
<Pressable <PillButton
style={[styles.filterBtn, !isDefaultFilters(filters) && styles.filterBtnActive]} label={`Filter${!isDefaultFilters(filters) ? ' 1' : ''}`}
active={!isDefaultFilters(filters)}
onPress={() => setFiltersOpen(true)} onPress={() => setFiltersOpen(true)}
> />
<Text style={styles.filterBtnText}>Filter{!isDefaultFilters(filters) ? '·' : ''}</Text> </Toolbar>
</Pressable>
</View>
<MovieFiltersSheet <MovieFiltersSheet
visible={filtersOpen} visible={filtersOpen}
value={filters} value={filters}
@ -152,7 +148,7 @@ export function MoviesScreen() {
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
} }
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null} ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }} contentContainerStyle={{ paddingBottom: 24 }}
columnWrapperStyle={{ gap: 8 }} columnWrapperStyle={{ gap: 8 }}
onEndReached={() => { onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage(); if (hasNextPage && !isFetchingNextPage) fetchNextPage();
@ -167,40 +163,11 @@ export function MoviesScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 4,
},
filterBtn: {
height: 40,
paddingHorizontal: 14,
borderRadius: 10,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.border,
alignItems: 'center',
justifyContent: 'center',
},
filterBtnActive: { filterBtnActive: {
backgroundColor: theme.accentDeep, backgroundColor: theme.accentDeep,
borderColor: theme.accentText, borderColor: theme.accentText,
}, },
filterBtnText: { color: theme.fg, fontSize: 13, fontWeight: '600' },
search: {
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
borderRadius: 10,
color: theme.fg,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 14,
},
subtitle: { color: theme.muted, fontSize: 12, paddingHorizontal: 4, paddingTop: 6, paddingBottom: 8 }, subtitle: { color: theme.muted, fontSize: 12, paddingHorizontal: 4, paddingTop: 6, paddingBottom: 8 },
error: { color: theme.bad, padding: 12 }, error: { color: theme.bad, padding: 12 },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },

View file

@ -10,12 +10,12 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { MigrationBanner } from '../components/MigrationBanner'; import { MigrationBanner } from '../components/MigrationBanner';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
@ -151,40 +151,24 @@ export function ScenesScreen({
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.toolbar}> <Toolbar>
<TextInput <SearchInput value={q} onChangeText={setQ} placeholder="search title…" />
style={styles.search}
value={q}
onChangeText={setQ}
placeholder="search title…"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
{q.trim().length > 0 ? ( {q.trim().length > 0 ? (
<Pressable <PillButton
label={alreadySaved ? '★ Saved' : '☆ Save'}
active={alreadySaved}
onPress={() => { onPress={() => {
const v = q.trim(); const v = q.trim();
if (v && !alreadySaved) addSaved.mutate(v); if (v && !alreadySaved) addSaved.mutate(v);
}} }}
disabled={alreadySaved} />
style={[styles.toolbarButton, alreadySaved && styles.toolbarButtonActive]}
>
<Text
style={[styles.toolbarButtonText, alreadySaved && { color: theme.accentText }]}
>
{alreadySaved ? '★ Saved' : '☆ Save'}
</Text>
</Pressable>
) : null} ) : null}
<Pressable <PillButton
label={`Filter${activeCount > 0 ? ` ${activeCount}` : ''}`}
active={activeCount > 0}
onPress={() => setFilterOpen(true)} onPress={() => setFilterOpen(true)}
style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]} />
> </Toolbar>
<Text style={[styles.toolbarButtonText, activeCount > 0 && { color: theme.accentText }]}>
Filter{activeCount > 0 ? ` ${activeCount}` : ''}
</Text>
</Pressable>
</View>
{saved.length > 0 ? ( {saved.length > 0 ? (
<ScrollView <ScrollView
@ -225,21 +209,21 @@ export function ScenesScreen({
<View style={styles.subToolbar}> <View style={styles.subToolbar}>
{hasCast ? ( {hasCast ? (
<Pressable <PillButton
style={styles.subButton} label="Performers"
tone="secondary"
compact
onPress={() => onPress={() =>
navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined) navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
} }
> />
<Text style={styles.subButtonText}>Performers</Text>
</Pressable>
) : null} ) : null}
<Pressable <PillButton
style={styles.subButton} label="Tags"
tone="secondary"
compact
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)} onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}
> />
<Text style={styles.subButtonText}>Tags</Text>
</Pressable>
<FavoritesButton /> <FavoritesButton />
</View> </View>
@ -352,14 +336,13 @@ function FavoritesButton() {
const hasNew = newTotal > 0; const hasNew = newTotal > 0;
return ( return (
<Pressable <PillButton
style={[styles.subButton, hasNew && styles.subButtonActive]} label={`★ ${hasNew ? newTotal : ''}`.trim()}
tone="secondary"
compact
active={hasNew}
onPress={() => navigation.navigate('Favorites')} onPress={() => navigation.navigate('Favorites')}
> />
<Text style={[styles.subButtonText, hasNew && styles.subButtonTextActive]}>
★ {hasNew ? newTotal : ''}
</Text>
</Pressable>
); );
} }
@ -429,28 +412,7 @@ function SceneRow({ scene }: { scene: SceneOut }) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 16 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 12 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
toolbarButton: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
paddingHorizontal: 16,
justifyContent: 'center',
},
toolbarButtonActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
toolbarButtonText: { color: theme.fg, fontWeight: '700' },
savedRow: { flexGrow: 0, marginBottom: 12 }, savedRow: { flexGrow: 0, marginBottom: 12 },
savedRowContent: { gap: 8, paddingRight: 12 }, savedRowContent: { gap: 8, paddingRight: 12 },
savedChip: { savedChip: {
@ -466,14 +428,6 @@ const styles = StyleSheet.create({
savedChipText: { color: theme.fg, fontSize: 13 }, savedChipText: { color: theme.fg, fontSize: 13 },
savedChipTextActive: { color: theme.accentText, fontWeight: '600' }, savedChipTextActive: { color: theme.accentText, fontWeight: '600' },
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 }, subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
subButton: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 8,
},
subButtonActive: { subButtonActive: {
backgroundColor: theme.accent, backgroundColor: theme.accent,
borderColor: theme.accentText, borderColor: theme.accentText,
@ -483,8 +437,6 @@ const styles = StyleSheet.create({
shadowRadius: 6, shadowRadius: 6,
elevation: 3, elevation: 3,
}, },
subButtonText: { color: theme.accentSecondary, fontSize: 13, fontWeight: '700' },
subButtonTextActive: { color: theme.fg },
row: { row: {
backgroundColor: theme.card, backgroundColor: theme.card,
borderColor: theme.border, borderColor: theme.border,

View file

@ -16,12 +16,12 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
import type { SourceOut, SourceRating } from '../types'; import type { SourceOut, SourceRating } from '../types';
type Order = 'popular' | 'recent'; type Order = 'popular' | 'recent';
@ -79,30 +79,13 @@ export function SitesScreen() {
</View> </View>
<Text style={styles.hint}>tap a tube → newest scenes · tap the ★ → rating breakdown</Text> <Text style={styles.hint}>tap a tube → newest scenes · tap the ★ → rating breakdown</Text>
<View style={styles.toolbar}> <Toolbar>
<TextInput <SearchInput value={q} onChangeText={setQ} placeholder="search site…" />
style={[styles.search, searchFocused && styles.searchFocused]} </Toolbar>
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search site…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/>
</View>
<View style={styles.segment}> <View style={styles.segment}>
<SegButton <PillButton label="Top" compact active={order === 'popular'} onPress={() => setOrder('popular')} />
active={order === 'popular'} <PillButton label="Recent" compact active={order === 'recent'} onPress={() => setOrder('recent')} />
onPress={() => setOrder('popular')}
label="Top"
/>
<SegButton
active={order === 'recent'}
onPress={() => setOrder('recent')}
label="Recent"
/>
</View> </View>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
@ -138,27 +121,6 @@ export function SitesScreen() {
); );
} }
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
// Lista Sites pokazuje display_name = domena (hqporner.com). User-report 18105d14: // Lista Sites pokazuje display_name = domena (hqporner.com). User-report 18105d14:
// pozbyć się suffixów com/org/itp. Strip końcowego TLD → czysta nazwa. // pozbyć się suffixów com/org/itp. Strip końcowego TLD → czysta nazwa.
function prettySiteName(name: string): string { function prettySiteName(name: string): string {
@ -303,7 +265,7 @@ function RatingModal({ source, onClose }: { source: SourceOut | null; onClose: (
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
headerRow: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
@ -320,41 +282,8 @@ const styles = StyleSheet.create({
}, },
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' }, headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 }, hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
segment: { flexDirection: 'row', gap: 8, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8 },
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: { color: theme.muted, fontWeight: '700', fontSize: 13 },
segButtonTextActive: { color: theme.fg },
gridRow: { gap: 10, marginBottom: 10 }, gridRow: { gap: 10, marginBottom: 10 },
chip: { chip: {

127
mobile/src/ui.tsx Normal file
View file

@ -0,0 +1,127 @@
/**
* Wspólny zestaw kontrolek nagłówka ekranów-list (Scenes, Movies, Sites, JAV, Anime).
*
* Po co: każdy ekran miał WŁASNĄ kopię stylów `search`/`toolbar`/przycisku filtra,
* definiowaną niezależnie, więc rozjechały się na wszystkim naraz (zgłoszenie 2026-08-18,
* cztery zrzuty obok siebie): róg pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14, odstępy
* 12 vs 8, dwa promienie pigułek (8 i 12), a etykiety miały `fontWeight:'700'` BEZ
* `fontFamily` — RN nie syntezuje pogrubienia dla fontu custom, więc renderowały się
* chudym Regularem (stąd wrażenie „innych czcionek"). Jeden moduł = jeden wygląd.
*
* Zasady: jedna wysokość kontrolek w wierszu (pole = przycisk filtra), jeden promień,
* jedna ramka, waga przez `fontFamily` (nigdy przez `fontWeight` na custom foncie),
* stan aktywny robi kolor (accentText + delikatny tint), nie kształt.
*/
import React from 'react';
import {
Pressable,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
View,
ViewStyle,
} from 'react-native';
import { fonts, theme } from './theme';
/** Poziomy padding treści list i wysokość kontrolek w wierszu narzędzi. Wspólne, żeby
* pola i przyciski na każdej zakładce zaczynały się i kończyły w tej samej linii. */
export const SCREEN_PAD_H = 16;
export const SCREEN_PAD_TOP = 14;
export const CONTROL_H = 44;
const RADIUS = 12;
/** Pole wyszukiwania. Ramka focus zarządzana wewnątrz, żeby każdy ekran nie powtarzał
* tego samego `searchFocused`. Wypełnia szerokość (flex:1) w wierszu z przyciskiem. */
export function SearchInput(props: TextInputProps) {
const [focused, setFocused] = React.useState(false);
return (
<TextInput
placeholderTextColor={theme.muted}
autoCapitalize="none"
{...props}
onFocus={(e) => {
setFocused(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
props.onBlur?.(e);
}}
style={[styles.search, focused && styles.searchFocused, props.style]}
/>
);
}
/** Pigułka-przycisk (Filter, Performers, Tags, ★, Top/Recent). `compact` = niższy wariant
* dla wiersza akcji pod polem. `active` przełącza kolor, nie kształt. */
export function PillButton({
label,
active,
onPress,
onLongPress,
compact,
tone = 'accent',
style,
}: {
label: string;
active?: boolean;
onPress?: () => void;
onLongPress?: () => void;
compact?: boolean;
/** 'accent' = czerwień (filtr/aktywny), 'secondary' = amber (Performers/Tags). */
tone?: 'accent' | 'secondary';
style?: StyleProp<ViewStyle>;
}) {
const textColor = active ? theme.accentText : tone === 'secondary' ? theme.accentSecondary : theme.fg;
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
style={[
styles.pill,
compact ? styles.pillCompact : styles.pillTall,
active && styles.pillActive,
style,
]}
>
<Text style={[styles.pillText, { color: textColor }]}>{label}</Text>
</Pressable>
);
}
/** Wiersz narzędzi: pole + przyciski, jednakowy odstęp i margines na każdym ekranie. */
export function Toolbar({ children }: { children: React.ReactNode }) {
return <View style={styles.toolbar}>{children}</View>;
}
const styles = StyleSheet.create({
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
search: {
flex: 1,
height: CONTROL_H,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
borderRadius: RADIUS,
color: theme.fg,
paddingHorizontal: 14,
fontFamily: fonts.body,
fontSize: 15,
},
searchFocused: { borderColor: theme.borderFocus },
pill: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: RADIUS,
alignItems: 'center',
justifyContent: 'center',
},
pillTall: { height: CONTROL_H, paddingHorizontal: 16 },
pillCompact: { height: 36, paddingHorizontal: 14 },
pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
pillText: { fontFamily: fonts.medium, fontSize: 14 },
});