Split settings pages into tabs, hiding other sections for accessibility

Introduces a category selection mechanism for the settings page, allowing users to filter settings by category or view all settings. Updates the sidebar to include an 'All Settings' option and highlights the selected category. Adjusts the settings layout and section rendering logic to respect the selected category or search query.
This commit is contained in:
Pas 2025-11-07 22:57:25 -07:00
parent 70534fa080
commit c792d4b829
3 changed files with 257 additions and 175 deletions

View file

@ -942,6 +942,9 @@
"search": {
"placeholder": "Search settings..."
},
"all": {
"title": "All Settings"
},
"account": {
"accountDetails": {
"deviceNameLabel": "Device name",

View file

@ -43,11 +43,15 @@ import { AppInfoPart } from "./parts/settings/AppInfoPart";
import { PreferencesPart } from "./parts/settings/PreferencesPart";
function SettingsLayout(props: {
className?: string;
children: React.ReactNode;
searchQuery: string;
onSearchChange: (value: string, force: boolean) => void;
onSearchUnFocus: (newSearch?: string) => void;
selectedCategory: string | null;
setSelectedCategory: (category: string | null) => void;
}) {
const { className } = props;
const { t } = useTranslation();
const { isMobile } = useIsMobile();
const searchRef = useRef<HTMLInputElement>(null);
@ -105,8 +109,12 @@ function SettingsLayout(props: {
)}
data-settings-content
>
<SidebarPart />
<div>{props.children}</div>
<SidebarPart
selectedCategory={props.selectedCategory}
setSelectedCategory={props.setSelectedCategory}
searchQuery={props.searchQuery}
/>
<div className={className}>{props.children}</div>
<div className="block lg:hidden">
<AppInfoPart />
</div>
@ -161,15 +169,29 @@ export function AccountSettings(props: {
export function SettingsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
useEffect(() => {
const hash = window.location.hash;
if (hash) {
const categoryId = hash.substring(1); // Remove the # symbol
// Check if it's a valid settings category
const validCategories = [
"settings-account",
"settings-preferences",
"settings-appearance",
"settings-captions",
"settings-connection",
];
if (validCategories.includes(categoryId)) {
setSelectedCategory(categoryId);
}
const element = document.querySelector(hash);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { t } = useTranslation();
@ -181,6 +203,10 @@ export function SettingsPage() {
// Simple text search with highlighting
const handleSearchChange = useCallback((value: string, _force: boolean) => {
setSearchQuery(value);
// When searching, clear category selection to show all sections
if (value.trim()) {
setSelectedCategory(null);
}
// Remove existing highlights
const existingHighlights = document.querySelectorAll(".search-highlight");
@ -637,103 +663,134 @@ export function SettingsPage() {
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchUnFocus={handleSearchUnFocus}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
className="space-y-28"
>
<div id="settings-account">
<Heading1 border className="!mb-0">
{t("settings.account.title")}
</Heading1>
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) => (s ? { ...s, colorA: v } : undefined));
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) => (s ? { ...s, colorB: v } : undefined))
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-account") && (
<div id="settings-account">
<Heading1 border className="!mb-0">
{t("settings.account.title")}
</Heading1>
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) =>
s ? { ...s, colorA: v } : undefined,
);
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) =>
s ? { ...s, colorB: v } : undefined,
)
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
}
/>
) : (
<RegisterCalloutPart />
)}
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-preferences") && (
<div id="settings-preferences">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
enableAutoplay={state.enableAutoplay.state}
setEnableAutoplay={state.enableAutoplay.set}
enableSkipCredits={state.enableSkipCredits.state}
setEnableSkipCredits={state.enableSkipCredits.set}
sourceOrder={availableSources}
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
enableLastSuccessfulSource={
state.enableLastSuccessfulSource.state
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
setEnableLastSuccessfulSource={
state.enableLastSuccessfulSource.set
}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
enableHoldToBoost={state.enableHoldToBoost.state}
setEnableHoldToBoost={state.enableHoldToBoost.set}
manualSourceSelection={state.manualSourceSelection.state}
setManualSourceSelection={state.manualSourceSelection.set}
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
/>
) : (
<RegisterCalloutPart />
)}
</div>
<div id="settings-preferences" className="mt-28">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
enableAutoplay={state.enableAutoplay.state}
setEnableAutoplay={state.enableAutoplay.set}
enableSkipCredits={state.enableSkipCredits.state}
setEnableSkipCredits={state.enableSkipCredits.set}
sourceOrder={availableSources}
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
enableLastSuccessfulSource={state.enableLastSuccessfulSource.state}
setEnableLastSuccessfulSource={state.enableLastSuccessfulSource.set}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
enableHoldToBoost={state.enableHoldToBoost.state}
setEnableHoldToBoost={state.enableHoldToBoost.set}
manualSourceSelection={state.manualSourceSelection.state}
setManualSourceSelection={state.manualSourceSelection.set}
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
/>
</div>
<div id="settings-appearance" className="mt-28">
<AppearancePart
active={previewTheme ?? "default"}
inUse={activeTheme ?? "default"}
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
/>
</div>
<div id="settings-captions" className="mt-28">
<CaptionsPart
styling={state.subtitleStyling.state}
setStyling={state.subtitleStyling.set}
/>
</div>
<div id="settings-connection" className="mt-28">
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set}
realDebridKey={state.realDebridKey.state}
setRealDebridKey={state.realDebridKey.set}
proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set}
/>
</div>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-appearance") && (
<div id="settings-appearance">
<AppearancePart
active={previewTheme ?? "default"}
inUse={activeTheme ?? "default"}
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-captions") && (
<div id="settings-captions">
<CaptionsPart
styling={state.subtitleStyling.state}
setStyling={state.subtitleStyling.set}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-connection") && (
<div id="settings-connection">
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set}
realDebridKey={state.realDebridKey.state}
setRealDebridKey={state.realDebridKey.set}
proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set}
/>
</div>
)}
</SettingsLayout>
<Transition
animation="fade"

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Sticky from "react-sticky-el";
@ -11,82 +11,93 @@ import { AppInfoPart } from "./AppInfoPart";
const rem = 16;
export function SidebarPart() {
export function SidebarPart(props: {
selectedCategory: string | null;
setSelectedCategory: (category: string | null) => void;
searchQuery: string;
}) {
const { t } = useTranslation();
const { isMobile } = useIsMobile();
const [activeLink, setActiveLink] = useState("");
const settingLinks = [
{
textKey: "settings.account.title",
id: "settings-account",
icon: Icons.USER,
},
{
textKey: "settings.preferences.title",
id: "settings-preferences",
icon: Icons.SETTINGS,
},
{
textKey: "settings.appearance.title",
id: "settings-appearance",
icon: Icons.BRUSH,
},
{
textKey: "settings.subtitles.title",
id: "settings-captions",
icon: Icons.CAPTIONS,
},
{
textKey: "settings.connections.title",
id: "settings-connection",
icon: Icons.LINK,
},
];
const settingLinks = useMemo(
() => [
{
textKey: "settings.account.title",
id: "settings-account",
icon: Icons.USER,
},
{
textKey: "settings.preferences.title",
id: "settings-preferences",
icon: Icons.SETTINGS,
},
{
textKey: "settings.appearance.title",
id: "settings-appearance",
icon: Icons.BRUSH,
},
{
textKey: "settings.subtitles.title",
id: "settings-captions",
icon: Icons.CAPTIONS,
},
{
textKey: "settings.connections.title",
id: "settings-connection",
icon: Icons.LINK,
},
],
[],
);
useEffect(() => {
function recheck() {
const windowHeight =
window.innerHeight || document.documentElement.clientHeight;
const centerTarget = windowHeight / 4;
// Only track active link when searching (to show all sections)
if (props.searchQuery.trim()) {
const recheck = () => {
const windowHeight =
window.innerHeight || document.documentElement.clientHeight;
const centerTarget = windowHeight / 4;
const viewList = settingLinks
.map((link) => {
const el = document.getElementById(link.id);
if (!el) return { distance: Infinity, link: link.id };
const rect = el.getBoundingClientRect();
const distanceTop = Math.abs(centerTarget - rect.top);
const distanceBottom = Math.abs(centerTarget - rect.bottom);
const distance = Math.min(distanceBottom, distanceTop);
return { distance, link: link.id };
})
.sort((a, b) => a.distance - b.distance);
const viewList = settingLinks
.map((link) => {
const el = document.getElementById(link.id);
if (!el) return { distance: Infinity, link: link.id };
const rect = el.getBoundingClientRect();
const distanceTop = Math.abs(centerTarget - rect.top);
const distanceBottom = Math.abs(centerTarget - rect.bottom);
const distance = Math.min(distanceBottom, distanceTop);
return { distance, link: link.id };
})
.sort((a, b) => a.distance - b.distance);
// Check if user has scrolled past the bottom of the page
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
setActiveLink(settingLinks[settingLinks.length - 1].id);
} else {
// Check if user has scrolled past the bottom of the page
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
setActiveLink(settingLinks[settingLinks.length - 1].id);
return;
}
// shortest distance to the part of the screen we want is the active link
setActiveLink(viewList[0]?.link ?? "");
}
};
document.addEventListener("scroll", recheck);
recheck();
return () => {
document.removeEventListener("scroll", recheck);
};
}
document.addEventListener("scroll", recheck);
recheck();
// When not searching, set active link to selected category
setActiveLink(props.selectedCategory || "");
}, [props.searchQuery, props.selectedCategory, settingLinks]);
return () => {
document.removeEventListener("scroll", recheck);
};
});
const scrollTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (!el) return null;
const y = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: y - 120,
behavior: "smooth",
});
}, []);
const selectCategory = useCallback(
(id: string | null) => {
// Set the selected category when clicking a sidebar link
// null means "All Settings" - show all sections
props.setSelectedCategory(id);
},
[props],
);
return (
<div className="text-settings-sidebar-type-inactive sidebar-boundary">
@ -97,21 +108,32 @@ export function SidebarPart() {
hideOnBoundaryHit={false}
boundaryElement=".sidebar-boundary"
>
<div className="hidden lg:block">
<SidebarSection title={t("global.pages.settings")}>
{settingLinks.map((v) => (
<SidebarLink
icon={v.icon}
active={v.id === activeLink}
onClick={() => scrollTo(v.id)}
key={v.id}
>
{t(v.textKey)}
</SidebarLink>
))}
</SidebarSection>
<Divider />
</div>
<SidebarSection title={t("global.pages.settings")}>
<SidebarLink
icon={Icons.GEAR}
active={
(!props.searchQuery.trim() && props.selectedCategory === null) ||
(props.searchQuery.trim() ? activeLink === "" : false)
}
onClick={() => selectCategory(null)}
>
{t("settings.all.title")}
</SidebarLink>
{settingLinks.map((v) => (
<SidebarLink
icon={v.icon}
active={
v.id === activeLink ||
(!props.searchQuery.trim() && v.id === props.selectedCategory)
}
onClick={() => selectCategory(v.id)}
key={v.id}
>
{t(v.textKey)}
</SidebarLink>
))}
</SidebarSection>
<Divider />
<div className="hidden lg:block">
<AppInfoPart />
</div>