diff --git a/README.md b/README.md index 98c0032..471fdc7 100644 --- a/README.md +++ b/README.md @@ -98,17 +98,18 @@ All modules from the original plan are built: and has an "Export CSV" button next to it that exports whatever's currently sorted/filtered. Tables that can realistically grow large (DNS zones/records, IP Addresses, Secrets, Servers, Audit Log, and each - integration's device/container/guest/repo/template list) are paginated - 20 rows at a time — CSV export still covers every sorted/filtered row, - not just the current page. + integration's device/container/guest/repo/template list) are paginated, + 20 rows per page by default — adjustable under Settings → Display — and + CSV export still covers every sorted/filtered row, not just the current + page. - **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP, generic webhook) with per-channel test buttons, per-event toggles (DNS record added/updated/deleted, daily secret-expiry reminder and daily Tailscale key-expiry reminder — both sharing one configurable time/timezone), badge-color customization for both DNS - providers and integration types, and a date/time display format - (date order, 12/24-hour clock) applied consistently to every table in - the app. + providers and integration types, and a **Display** tab (date order, + 12/24-hour clock, and rows-per-page for every paginated table) applied + consistently across the app. All six integrations follow the same config-in-UI + encrypted-credentials pattern, added (and edited — e.g. to rotate an expired API token without diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 342ad88..f257b46 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -66,7 +66,10 @@ const updateSchema = z.object({ .optional(), providerColors: z.record(z.string()).optional(), integrationColors: z.record(z.string()).optional(), - display: z.object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]) }).partial().optional(), + display: z + .object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]), pageSize: z.number().int().min(5).max(500) }) + .partial() + .optional(), }); settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => { diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index a6d7d61..40e3c6f 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -53,6 +53,8 @@ export type TimeFormat = "24h" | "12h"; export interface DisplaySettings { dateFormat: DateFormat; timeFormat: TimeFormat; + /** Rows per page for every paginated table in the app. */ + pageSize: number; } export interface AppSettings { @@ -82,7 +84,7 @@ const DEFAULTS: AppSettings = { }, providerColors: {}, integrationColors: {}, - display: { dateFormat: "ymd", timeFormat: "24h" }, + display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index 50d47b2..0bc4413 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,10 +21,11 @@ import Synology from "./pages/Synology"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; -import DateTimeSettings from "./pages/settings/DateTimeSettings"; +import DisplaySettings from "./pages/settings/DisplaySettings"; import CacheSettings from "./pages/settings/CacheSettings"; import AppShell from "./layout/AppShell"; import { setDateTimeSettings } from "./utils/date"; +import { setPageSize } from "./utils/pageSize"; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; @@ -58,7 +59,10 @@ export default function App() { setUser(null); }, ); - api.settings.display().then((res) => setDateTimeSettings(res.display)).catch(() => {}); + api.settings.display().then((res) => { + setDateTimeSettings(res.display); + setPageSize(res.display.pageSize); + }).catch(() => {}); }, []); if (user === undefined) { @@ -120,7 +124,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e9b624b..c5b3060 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -150,6 +150,7 @@ export type TimeFormat = "24h" | "12h"; export interface DisplaySettings { dateFormat: DateFormat; timeFormat: TimeFormat; + pageSize: number; } export interface AppSettings { diff --git a/web/src/hooks/usePagination.ts b/web/src/hooks/usePagination.ts index bf74b9a..a8c78c8 100644 --- a/web/src/hooks/usePagination.ts +++ b/web/src/hooks/usePagination.ts @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { getPageSize } from "../utils/pageSize"; /** * Client-side pagination over an already-fetched array. Purely a display @@ -10,8 +11,11 @@ import { useMemo, useState } from "react"; * integration, a filter that shrinks the result set) can never strand the * view on a page that no longer exists — no caller-side "reset page on * filter change" wiring required. + * + * Defaults to the app-wide "rows per page" setting (Settings → Display) — + * pass an explicit pageSize only to override it for one specific table. */ -export function usePagination(items: T[] | null | undefined, pageSize = 20) { +export function usePagination(items: T[] | null | undefined, pageSize = getPageSize()) { const [page, setPage] = useState(0); const totalCount = items?.length ?? 0; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index cdb2ee8..86717a5 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -3,7 +3,7 @@ import { NavLink, Outlet } from "react-router-dom"; const SUB_NAV = [ { to: "/settings/notifications", label: "Notifications" }, { to: "/settings/badges", label: "Badges" }, - { to: "/settings/date-time", label: "Date & Time" }, + { to: "/settings/display", label: "Display" }, { to: "/settings/cache", label: "Cache" }, ]; diff --git a/web/src/pages/settings/DateTimeSettings.tsx b/web/src/pages/settings/DateTimeSettings.tsx deleted file mode 100644 index 24b3bc9..0000000 --- a/web/src/pages/settings/DateTimeSettings.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect, useState } from "react"; -import { api, type AppSettings, type DateFormat, type TimeFormat } from "../../api/client"; -import { setDateTimeSettings } from "../../utils/date"; - -const DATE_FORMAT_OPTIONS: { value: DateFormat; label: string; example: string }[] = [ - { value: "ymd", label: "Year-Month-Day", example: "2026-09-15" }, - { value: "dmy", label: "Day/Month/Year", example: "15/09/2026" }, - { value: "mdy", label: "Month/Day/Year", example: "09/15/2026" }, -]; - -function previewTime(timeFormat: TimeFormat): string { - return timeFormat === "12h" ? "2:30:00 PM" : "14:30:00"; -} - -export default function DateTimeSettings() { - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [dateFormat, setDateFormatState] = useState("ymd"); - const [timeFormat, setTimeFormatState] = useState("24h"); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [saveError, setSaveError] = useState(null); - - useEffect(() => { - api.settings - .get() - .then((res: { settings: AppSettings }) => { - setDateFormatState(res.settings.display.dateFormat); - setTimeFormatState(res.settings.display.timeFormat); - }) - .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) - .finally(() => setLoading(false)); - }, []); - - async function handleSave() { - setSaving(true); - setSaveError(null); - setSaved(false); - try { - const res = await api.settings.update({ display: { dateFormat, timeFormat } }); - setDateTimeSettings(res.settings.display); - setSaved(true); - setTimeout(() => setSaved(false), 3000); - } catch (err) { - setSaveError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } - } - - if (loading) return
Loading…
; - - const dateExample = DATE_FORMAT_OPTIONS.find((o) => o.value === dateFormat)?.example ?? ""; - - return ( - <> -
-

Date & Time

-
- {saveError && {saveError}} - {saved && ✓ Settings saved} - -
-
- - {loadError &&
{loadError}
} - -
-
-

Display format

-
-
-

- Controls how dates and times are shown throughout the app — tables previously mixed formats depending on - the page; this makes them consistent everywhere. -

-
-
- - -
-
- - -
-
-
-
Preview
-
- - {dateExample} {previewTime(timeFormat)} - -
-
-
-
- - ); -} diff --git a/web/src/pages/settings/DisplaySettings.tsx b/web/src/pages/settings/DisplaySettings.tsx new file mode 100644 index 0000000..934d52e --- /dev/null +++ b/web/src/pages/settings/DisplaySettings.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { api, type AppSettings, type DateFormat, type TimeFormat } from "../../api/client"; +import { setDateTimeSettings } from "../../utils/date"; +import { setPageSize } from "../../utils/pageSize"; + +const DATE_FORMAT_OPTIONS: { value: DateFormat; label: string; example: string }[] = [ + { value: "ymd", label: "Year-Month-Day", example: "2026-09-15" }, + { value: "dmy", label: "Day/Month/Year", example: "15/09/2026" }, + { value: "mdy", label: "Month/Day/Year", example: "09/15/2026" }, +]; + +function previewTime(timeFormat: TimeFormat): string { + return timeFormat === "12h" ? "2:30:00 PM" : "14:30:00"; +} + +export default function DisplaySettings() { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [dateFormat, setDateFormatState] = useState("ymd"); + const [timeFormat, setTimeFormatState] = useState("24h"); + const [pageSize, setPageSizeState] = useState(20); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + api.settings + .get() + .then((res: { settings: AppSettings }) => { + setDateFormatState(res.settings.display.dateFormat); + setTimeFormatState(res.settings.display.timeFormat); + setPageSizeState(res.settings.display.pageSize); + }) + .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setSaveError(null); + setSaved(false); + try { + const res = await api.settings.update({ display: { dateFormat, timeFormat, pageSize } }); + setDateTimeSettings(res.settings.display); + setPageSize(res.settings.display.pageSize); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + if (loading) return
Loading…
; + + const dateExample = DATE_FORMAT_OPTIONS.find((o) => o.value === dateFormat)?.example ?? ""; + + return ( + <> +
+

Display

+
+ {saveError && {saveError}} + {saved && ✓ Settings saved} + +
+
+ + {loadError &&
{loadError}
} + +
+
+
+
+

Date & time format

+
+
+

+ Controls how dates and times are shown throughout the app — tables previously mixed formats + depending on the page; this makes them consistent everywhere. +

+
+
+ + +
+
+ + +
+
+
+
Preview
+
+ + {dateExample} {previewTime(timeFormat)} + +
+
+
+
+
+ +
+
+
+

Table pagination

+
+
+

+ How many rows a paginated table (Secrets, IP Addresses, DNS records, Servers, Audit Log, and each + integration's device/guest/repo/template list) shows per page. +

+ + setPageSizeState(Number(e.target.value))} + /> +
Between 5 and 500.
+
+
+
+
+ + ); +} diff --git a/web/src/utils/pageSize.ts b/web/src/utils/pageSize.ts new file mode 100644 index 0000000..720cf5c --- /dev/null +++ b/web/src/utils/pageSize.ts @@ -0,0 +1,10 @@ +let current = 20; + +/** Called once at app startup (and whenever the setting is changed) so usePagination() picks it up without prop-drilling — same pattern as utils/date.ts's date/time settings. */ +export function setPageSize(size: number): void { + current = size; +} + +export function getPageSize(): number { + return current; +}