From 62b3c0aa5e496b2dc0873670f119e00493edeb0c Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 20 Aug 2026 12:18:55 +0200 Subject: [PATCH] fix: rework settings section navigation --- apps/docs/docs/management/settings/index.mdx | 2 +- .../_components/board-settings-form.tsx | 6 +- .../_components/search-settings-form.tsx | 6 +- .../_components/settings-form.module.css | 54 ++++--- .../settings/_components/settings-form.tsx | 152 +++++++++++------- .../src/app/[locale]/manage/settings/page.tsx | 14 +- 6 files changed, 147 insertions(+), 87 deletions(-) diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 7ab1ef4e3..3cf056650 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -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 diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index 2a5b733ff..d520c1f02 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -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; + 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 ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx index 904dd4b08..ad48a97cc 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx @@ -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; + 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 ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.module.css b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.module.css index c4e3f217b..be2cf5ee4 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.module.css +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.module.css @@ -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; } } diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx index f267f00b8..6b7c9e107 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -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 (
void handleSubmitAsync(values))}> - - - }> - {tSettings("section.board.title")} - - }> - {tSettings("section.user.title")} - - }> - {tSettings("section.search.title")} - - }> - {tSettings("section.appearance.title")} - - }> - {tSettings("section.culture.title")} - - }> - {tSettings("section.analytics.title")} - - }> - {tSettings("section.crawlingAndIndexing.title")} - - +
+ - - - - - - - - - - - - - - - - - - - - - - + +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+
{form.isDirty() && ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 242cd44c1..ddfa33a24 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -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 ( - + ); }