feat(settings): add profile hero and settings search

This commit is contained in:
Gauvain
2026-06-03 23:48:22 +02:00
parent e476e0b4d9
commit 6a38c393e6
6 changed files with 258 additions and 32 deletions

View File

@@ -1,16 +1,19 @@
import { useNavigation } from "expo-router";
import { t } from "i18next";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { SettingsHero } from "@/components/settings/index/SettingsHero";
import { SettingsRow } from "@/components/settings/index/SettingsRow";
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
import { SettingsSection } from "@/components/settings/index/SettingsSection";
import {
SETTINGS_CATALOG,
type SettingsEntry,
type SettingsTarget,
} from "@/components/settings/index/settingsCatalog";
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
import {
QuickConnectSheet,
type QuickConnectSheetRef,
@@ -19,7 +22,6 @@ import { StorageSettings } from "@/components/settings/StorageSettings";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin } from "@/providers/JellyfinProvider";
// TV-specific settings component
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
function SettingsMobile() {
@@ -28,7 +30,10 @@ function SettingsMobile() {
const { logout } = useJellyfin();
const navigation = useNavigation();
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
const [query, setQuery] = useState("");
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
const results = useSettingsSearch(query);
const searching = query.trim().length > 0;
useEffect(() => {
navigation.setOptions({
@@ -42,19 +47,20 @@ function SettingsMobile() {
});
}, []);
const handleEntry = (entry: SettingsEntry) => {
if (entry.target.type === "action") {
if (entry.target.action === "quickConnect") {
const handleTarget = (target: SettingsTarget) => {
if (target.type === "action") {
if (target.action === "quickConnect") {
quickConnectRef.current?.present();
}
return;
}
router.push(entry.target.route as any);
router.push(target.route as any);
};
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
@@ -62,35 +68,65 @@ function SettingsMobile() {
paddingBottom: 32,
}}
>
<View className='mx-3 mb-5'>
<AppLanguageSelector />
</View>
{!searching && (
<SettingsHero
onPress={() => router.push("/settings/account/page" as any)}
/>
)}
<SettingsSearchBar value={query} onChange={setQuery} />
{SETTINGS_CATALOG.map((section) => {
const entries = section.entries.filter(
(e) => !e.platforms || e.platforms.includes(os),
);
if (entries.length === 0) return null;
return (
<SettingsSection key={section.id} title={t(section.titleKey)}>
{entries.map((e, i) => (
{searching ? (
<SettingsSection title={t("home.settings.search_results")}>
{results.length === 0 ? (
<View className='px-4 py-3'>
<Text className='text-[#9899A1]'>
{t("home.settings.search_no_results")}
</Text>
</View>
) : (
results.map((r, i) => (
<SettingsRow
key={e.id}
title={t(e.titleKey)}
icon={e.icon}
onPress={() => handleEntry(e)}
isLast={i === entries.length - 1}
key={r.id}
title={r.title}
icon={r.icon}
value={r.subtitle}
onPress={() => handleTarget(r.target)}
isLast={i === results.length - 1}
/>
))}
))
)}
</SettingsSection>
) : (
<>
<View className='mx-3 mb-5'>
<AppLanguageSelector />
</View>
{SETTINGS_CATALOG.map((section) => {
const entries = section.entries.filter(
(e) => !e.platforms || e.platforms.includes(os),
);
if (entries.length === 0) return null;
return (
<SettingsSection key={section.id} title={t(section.titleKey)}>
{entries.map((e, i) => (
<SettingsRow
key={e.id}
title={t(e.titleKey)}
icon={e.icon}
onPress={() => handleTarget(e.target)}
isLast={i === entries.length - 1}
/>
))}
</SettingsSection>
);
})}
<SettingsSection>
<View className='p-3'>
<StorageSettings />
</View>
</SettingsSection>
);
})}
<SettingsSection>
<View className='p-3'>
<StorageSettings />
</View>
</SettingsSection>
</>
)}
<QuickConnectSheet ref={quickConnectRef} />
</ScrollView>

View File

@@ -0,0 +1,76 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import type React from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
onPress,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const connected = Boolean(api && user);
const imageUrl =
api && user?.Id && user.PrimaryImageTag
? `${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}&quality=90`
: undefined;
const host = api?.basePath?.replace(/^https?:\/\//, "");
return (
<TouchableOpacity
onPress={onPress}
className='mx-3 mb-4 rounded-2xl overflow-hidden'
>
<LinearGradient
colors={["#241b33", "#15151a"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View className='flex-row items-center p-4'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: 52, height: 52, borderRadius: 26 }}
/>
) : (
<LinearGradient
colors={["#a855f7", "#6d28d9"]}
style={{
width: 52,
height: 52,
borderRadius: 26,
alignItems: "center",
justifyContent: "center",
}}
>
<Text className='text-white text-[22px] font-bold'>
{(user?.Name?.[0] ?? "?").toUpperCase()}
</Text>
</LinearGradient>
)}
<View className='flex-1 ml-3'>
<Text
className='text-white text-[18px] font-bold'
numberOfLines={1}
>
{user?.Name ?? ""}
</Text>
<View className='flex-row items-center mt-0.5'>
<View
className='w-2 h-2 rounded-full mr-1.5'
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
/>
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
{host}
</Text>
</View>
</View>
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
</View>
</LinearGradient>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,23 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import type React from "react";
import { TextInput, View } from "react-native";
export const SettingsSearchBar: React.FC<{
value: string;
onChange: (v: string) => void;
}> = ({ value, onChange }) => (
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
<Ionicons name='search' size={16} color='#76767c' />
<TextInput
value={value}
onChangeText={onChange}
placeholder={t("home.settings.search_placeholder")}
placeholderTextColor='#76767c'
className='flex-1 ml-2 text-white text-[15px]'
autoCapitalize='none'
autoCorrect={false}
clearButtonMode='while-editing'
/>
</View>
);

View File

@@ -0,0 +1,38 @@
export interface SearchableOption {
titleKey: string;
parentRoute: string;
parentTitleKey: string;
keywords?: string[];
}
/**
* Internal options of sub-pages, for deep search ("index + internal settings").
* SEED LIST — expand by auditing each sub-page component. Every titleKey/
* parentTitleKey MUST be a real existing i18n key.
*/
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
{
titleKey: "home.settings.media_controls.forward_skip_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["skip", "forward"],
},
{
titleKey: "home.settings.media_controls.rewind_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["rewind"],
},
{
titleKey: "home.settings.subtitles.subtitle_size",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "size"],
},
{
titleKey: "home.settings.gesture_controls.gesture_controls_title",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "brightness", "volume"],
},
];

View File

@@ -0,0 +1,50 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import { useMemo } from "react";
import { Platform } from "react-native";
import { matchesQuery } from "./searchFilter";
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
export interface SearchResult {
id: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
subtitle?: string;
target: SettingsTarget;
}
export const useSettingsSearch = (query: string): SearchResult[] => {
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
return useMemo(() => {
if (!query.trim()) return [];
const results: SearchResult[] = [];
for (const section of SETTINGS_CATALOG) {
for (const e of section.entries) {
if (e.platforms && !e.platforms.includes(os)) continue;
if (
matchesQuery({ title: t(e.titleKey), keywords: e.keywords }, query)
) {
results.push({
id: e.id,
title: t(e.titleKey),
icon: e.icon,
target: e.target,
});
}
}
}
for (const o of SETTINGS_SEARCH_INDEX) {
if (matchesQuery({ title: t(o.titleKey), keywords: o.keywords }, query)) {
results.push({
id: `${o.parentRoute}#${o.titleKey}`,
title: t(o.titleKey),
icon: "search",
subtitle: t(o.parentTitleKey),
target: { type: "route", route: o.parentRoute },
});
}
}
return results;
}, [query, os]);
};

View File

@@ -119,6 +119,9 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"search_placeholder": "Search settings",
"search_results": "Results",
"search_no_results": "No matching settings",
"switch_user": {
"title": "Switch User",
"account": "Account",