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[] = [
{
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',
date: 'August 2026',

View file

@ -11,7 +11,6 @@ import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
@ -25,6 +24,7 @@ import { MoviePosterCard } from '../components/MoviePosterCard';
import { getLastSeen, markSeen } from '../lib/seenStore';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
const PER_PAGE = 30;
// 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 (
<View style={styles.container}>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, { flex: 1 }]}
<Toolbar>
<SearchInput
placeholder="search movies"
placeholderTextColor={theme.muted}
value={q}
onChangeText={setQ}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
/>
<Pressable
style={[styles.filterBtn, !isDefaultFilters(filters) && styles.filterBtnActive]}
<PillButton
label={`Filter${!isDefaultFilters(filters) ? ' 1' : ''}`}
active={!isDefaultFilters(filters)}
onPress={() => setFiltersOpen(true)}
>
<Text style={styles.filterBtnText}>Filter{!isDefaultFilters(filters) ? '·' : ''}</Text>
</Pressable>
</View>
/>
</Toolbar>
<MovieFiltersSheet
visible={filtersOpen}
value={filters}
@ -152,7 +148,7 @@ export function MoviesScreen() {
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }}
contentContainerStyle={{ paddingBottom: 24 }}
columnWrapperStyle={{ gap: 8 }}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
@ -167,40 +163,11 @@ export function MoviesScreen() {
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg },
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',
},
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
filterBtnActive: {
backgroundColor: theme.accentDeep,
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 },
error: { color: theme.bad, padding: 12 },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },

View file

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

View file

@ -16,12 +16,12 @@ import {
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
import type { SourceOut, SourceRating } from '../types';
type Order = 'popular' | 'recent';
@ -79,30 +79,13 @@ export function SitesScreen() {
</View>
<Text style={styles.hint}>tap a tube → newest scenes · tap the ★ → rating breakdown</Text>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, searchFocused && styles.searchFocused]}
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search site…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/>
</View>
<Toolbar>
<SearchInput value={q} onChangeText={setQ} placeholder="search site…" />
</Toolbar>
<View style={styles.segment}>
<SegButton
active={order === 'popular'}
onPress={() => setOrder('popular')}
label="Top"
/>
<SegButton
active={order === 'recent'}
onPress={() => setOrder('recent')}
label="Recent"
/>
<PillButton label="Top" compact active={order === 'popular'} onPress={() => setOrder('popular')} />
<PillButton label="Recent" compact active={order === 'recent'} onPress={() => setOrder('recent')} />
</View>
{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:
// pozbyć się suffixów com/org/itp. Strip końcowego TLD → czysta nazwa.
function prettySiteName(name: string): string {
@ -303,7 +265,7 @@ function RatingModal({ source, onClose }: { source: SourceOut | null; onClose: (
}
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: {
flexDirection: 'row',
@ -320,41 +282,8 @@ const styles = StyleSheet.create({
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
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 },
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 },
});