fix: rework settings section navigation

This commit is contained in:
ajnart committed 2026-08-20 12:18:55 +02:00
1 parent a07e6a2188
commit 62b3c0aa5e
6 files changed
+147 -87

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
Choose a section from the settings navigation to edit its options. All sections are collected in a single form, so you can move between them without losing unsaved changes. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
Use the settings navigation to jump to a section. The highlighted entry follows the section currently in view, and all sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
@@ -2,7 +2,7 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<SectionCard title={tBoard("title")}>
@@ -2,7 +2,7 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<SectionCard title={tSearch("title")}>
@@ -1,35 +1,53 @@
.list {
align-self: flex-start;
flex-wrap: nowrap;
min-width: 14rem;
.layout {
align-items: start;
display: grid;
gap: var(--mantine-spacing-xl);
grid-template-columns: minmax(12rem, 15rem) minmax(0, 1fr);
}
.navigation {
max-height: calc(100vh - var(--app-shell-header-height) - var(--mantine-spacing-xl));
overflow-y: auto;
position: sticky;
top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
}
.tab {
justify-content: flex-start;
.tableOfContents {
gap: var(--mantine-spacing-xs);
}
.panel {
flex: 1;
.control {
text-align: start;
}
.sections {
min-width: 0;
padding-inline-start: var(--mantine-spacing-xl);
}
.section {
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
}
@mixin smaller-than $mantine-breakpoint-sm {
.list {
max-width: calc(100vw - var(--mantine-spacing-xl));
min-width: 0;
overflow-x: auto;
.layout {
gap: var(--mantine-spacing-lg);
grid-template-columns: minmax(0, 1fr);
}
.navigation {
max-height: none;
overflow: hidden;
position: static;
}
.tab {
flex: 0 0 auto;
.tableOfContents {
flex-direction: row;
overflow-x: auto;
padding-block-end: var(--mantine-spacing-xs);
}
.panel {
padding-block-start: var(--mantine-spacing-lg);
padding-inline-start: 0;
.control {
flex: 0 0 auto;
white-space: nowrap;
}
}
@@ -1,19 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconChartBar,
IconLanguage,
IconLayoutDashboard,
IconPalette,
IconSearch,
IconUsers,
IconWorldSearch,
} from "@tabler/icons-react";
import { Button, Stack, TableOfContents } from "@mantine/core";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -69,12 +60,13 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
interface SettingsFormProps {
initialData: ServerSettings;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const isDesktop = useMediaQuery("(min-width: 48em)");
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
@@ -178,56 +170,98 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
form.resetDirty();
};
const tableOfContentsData = [
{ id: "settings-board", value: tSettings("section.board.title"), depth: 1 },
{ id: "settings-user", value: tSettings("section.user.title"), depth: 1 },
{ id: "settings-search", value: tSettings("section.search.title"), depth: 1 },
{ id: "settings-appearance", value: tSettings("section.appearance.title"), depth: 1 },
{ id: "settings-culture", value: tSettings("section.culture.title"), depth: 1 },
{ id: "settings-analytics", value: tSettings("section.analytics.title"), depth: 1 },
{ id: "settings-crawling", value: tSettings("section.crawlingAndIndexing.title"), depth: 1 },
];
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<Tabs defaultValue="board" orientation={isDesktop ? "vertical" : "horizontal"}>
<TabsList className={classes.list} aria-label={tSettings("title")}>
<TabsTab value="board" className={classes.tab} leftSection={<IconLayoutDashboard size={18} />}>
{tSettings("section.board.title")}
</TabsTab>
<TabsTab value="user" className={classes.tab} leftSection={<IconUsers size={18} />}>
{tSettings("section.user.title")}
</TabsTab>
<TabsTab value="search" className={classes.tab} leftSection={<IconSearch size={18} />}>
{tSettings("section.search.title")}
</TabsTab>
<TabsTab value="appearance" className={classes.tab} leftSection={<IconPalette size={18} />}>
{tSettings("section.appearance.title")}
</TabsTab>
<TabsTab value="culture" className={classes.tab} leftSection={<IconLanguage size={18} />}>
{tSettings("section.culture.title")}
</TabsTab>
<TabsTab value="analytics" className={classes.tab} leftSection={<IconChartBar size={18} />}>
{tSettings("section.analytics.title")}
</TabsTab>
<TabsTab value="crawling" className={classes.tab} leftSection={<IconWorldSearch size={18} />}>
{tSettings("section.crawlingAndIndexing.title")}
</TabsTab>
</TabsList>
<div className={classes.layout}>
<nav className={classes.navigation} aria-label={tSettings("title")}>
<TableOfContents
classNames={{ root: classes.tableOfContents, control: classes.control }}
initialData={tableOfContentsData}
minDepthToOffset={1}
scrollSpyOptions={{
selector: "[data-settings-section]",
getDepth: () => 1,
getValue: (element) => element.getAttribute("data-label") ?? "",
offset: 80,
}}
getControlProps={({ data }) => ({
onClick: () => data.getNode().scrollIntoView({ block: "start" }),
children: data.value,
})}
variant="light"
/>
</nav>
<TabsPanel value="board" className={classes.panel}>
<BoardSettingsForm form={form} />
</TabsPanel>
<TabsPanel value="user" className={classes.panel}>
<UserSettingsForm form={form} />
</TabsPanel>
<TabsPanel value="search" className={classes.panel}>
<SearchSettingsForm form={form} />
</TabsPanel>
<TabsPanel value="appearance" className={classes.panel}>
<AppearanceSettingsForm form={form} />
</TabsPanel>
<TabsPanel value="culture" className={classes.panel}>
<CultureSettingsForm form={form} />
</TabsPanel>
<TabsPanel value="analytics" className={classes.panel}>
<AnalyticsSettings form={form} />
</TabsPanel>
<TabsPanel value="crawling" className={classes.panel}>
<CrawlingAndIndexingSettings form={form} />
</TabsPanel>
</Tabs>
<Stack className={classes.sections} gap="xl">
<section
id="settings-board"
className={classes.section}
data-settings-section
data-label={tSettings("section.board.title")}
>
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
</section>
<section
id="settings-user"
className={classes.section}
data-settings-section
data-label={tSettings("section.user.title")}
>
<UserSettingsForm form={form} />
</section>
<section
id="settings-search"
className={classes.section}
data-settings-section
data-label={tSettings("section.search.title")}
>
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
</section>
<section
id="settings-appearance"
className={classes.section}
data-settings-section
data-label={tSettings("section.appearance.title")}
>
<AppearanceSettingsForm form={form} />
</section>
<section
id="settings-culture"
className={classes.section}
data-settings-section
data-label={tSettings("section.culture.title")}
>
<CultureSettingsForm form={form} />
</section>
<section
id="settings-analytics"
className={classes.section}
data-settings-section
data-label={tSettings("section.analytics.title")}
>
<AnalyticsSettings form={form} />
</section>
<section
id="settings-crawling"
className={classes.section}
data-settings-section
data-label={tSettings("section.crawlingAndIndexing.title")}
>
<CrawlingAndIndexingSettings form={form} />
</section>
</Stack>
</div>
{form.isDirty() && (
<UnsavedChangesBar>
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
notFound();
}
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
api.serverSettings.getAll(),
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
]);
return (
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
<SettingsForm
initialData={serverSettings}
selectableBoards={selectableBoards}
selectableSearchEngines={selectableSearchEngines}
/>
</ManagePageLayout>
);
}