fix: rework settings section navigation
This commit is contained in:
1 parent
a07e6a2188
commit
62b3c0aa5e
6 files changed
+147
-87
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user