diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index f8c8509f57..42d86111aa 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -95,6 +95,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" }); const relativeTime = useTimeAgo(data.timestamp); const table = useTranslatedMantineReactTable({ + id: "manage-tools-docker", data: data.containers, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx index 9723171ebd..67640a5597 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -50,6 +50,7 @@ export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-configmaps", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx index 3787bbe8be..322b7cef8c 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -83,6 +83,7 @@ export function IngressesTable(initialData: IngressesTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-ingresses", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx index 2486a4ad0b..79ef607c35 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -67,6 +67,7 @@ export function NamespacesTable(initialData: NamespacesTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-namespaces", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx index 2fc69d51f7..4250b6b4b1 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -93,6 +93,7 @@ export function NodesTable(initialData: NodesListComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-nodes", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx index dd600b3877..e963a1bdaa 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -58,6 +58,7 @@ export function PodsTable(initialData: PodsTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-pods", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx index d0a902f224..f9872e4e0d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -53,6 +53,7 @@ export function SecretsTable(initialData: SecretsTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-secrets", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx index 05a7f3cb9b..df71f9ce5a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -72,6 +72,7 @@ export function ServicesTable(initialData: ServicesTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-services", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx index e911ee7a6d..0d37f70b01 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -77,6 +77,7 @@ export function VolumesTable(initialData: VolumesTableComponentProps) { }); const table = useTranslatedMantineReactTable({ + id: "manage-tools-kubernetes-volumes", data, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/tasks-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/tasks-table.tsx index ceb27aa812..bc6fe84937 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/tasks-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/tasks/_components/tasks-table.tsx @@ -373,6 +373,7 @@ export const TasksTable = ({ initialJobs }: TasksTableProps) => { }; const table = useTranslatedMantineReactTable({ + id: "manage-tools-tasks", data: jobs, enableDensityToggle: false, enableColumnActions: false, diff --git a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx index 22f4d1b885..0180d0e880 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx @@ -59,6 +59,7 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled ); const table = useTranslatedMantineReactTable({ + id: "manage-users", columns, data, enableRowSelection: true, diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index 5fa1b5effe..1350094d1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -53,6 +53,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps ); const table = useTranslatedMantineReactTable({ + id: "manage-users-invites", columns, data, positionActionsColumn: "last", diff --git a/packages/modals-collection/src/search-engines/request-media-modal.tsx b/packages/modals-collection/src/search-engines/request-media-modal.tsx index efb92cf7a3..db3e1b1b8f 100644 --- a/packages/modals-collection/src/search-engines/request-media-modal.tsx +++ b/packages/modals-collection/src/search-engines/request-media-modal.tsx @@ -49,6 +49,7 @@ export const RequestMediaModal = createModal(({ actions, ); const table = useTranslatedMantineReactTable({ + id: "modal-request-media", columns, data: data && "seasons" in data ? data.seasons : [], enableColumnActions: false, diff --git a/packages/ui/src/hooks/use-translated-mantine-react-table.ts b/packages/ui/src/hooks/use-translated-mantine-react-table.ts index 4a071c031b..6a9bebf781 100644 --- a/packages/ui/src/hooks/use-translated-mantine-react-table.ts +++ b/packages/ui/src/hooks/use-translated-mantine-react-table.ts @@ -1,25 +1,318 @@ import { useParams } from "next/navigation"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table"; +import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { + MRT_ColumnFiltersState, + MRT_ColumnOrderState, + MRT_ColumnPinningState, + MRT_ColumnSizingState, + MRT_DensityState, + MRT_PaginationState, + MRT_RowData, + MRT_SortingState, + MRT_TableOptions, + MRT_VisibilityState, +} from "mantine-react-table"; import { useMantineReactTable } from "mantine-react-table"; import type { SupportedLanguage } from "@homarr/translation"; import { localeConfigurations } from "@homarr/translation"; +type PersistedMrtState = { + v: 1; + columnVisibility?: MRT_VisibilityState; + columnOrder?: MRT_ColumnOrderState; + columnSizing?: MRT_ColumnSizingState; + columnPinning?: MRT_ColumnPinningState; + density?: MRT_DensityState; + pagination?: MRT_PaginationState; + sorting?: MRT_SortingState; + globalFilter?: string; + columnFilters?: MRT_ColumnFiltersState; +}; + +const storagePrefix = "homarr:mrt"; + +type PreferenceKey = Exclude; + +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +const applyUpdater = (updater: T | ((prev: T) => T), prev: T): T => { + if (typeof updater === "function") { + return (updater as (prev: T) => T)(prev); + } + return updater; +}; + +const pickInitial = (persistedValue: T | undefined, initialValue: T | undefined, fallback: T): T => { + if (persistedValue !== undefined) return persistedValue; + if (initialValue !== undefined) return initialValue; + return fallback; +}; + +type PreferencesState = Required>; + +type PreferencesAction = + | { type: "hydrate"; values: Partial } + | { type: "set"; key: keyof PreferencesState; value: PreferencesState[keyof PreferencesState] }; + +const preferencesReducer = (state: PreferencesState, action: PreferencesAction): PreferencesState => { + if (action.type === "hydrate") { + return { ...state, ...action.values }; + } + return { ...state, [action.key]: action.value } as PreferencesState; +}; + +const readPersistedMrtState = (key: string): PersistedMrtState | null => { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + if (!("v" in parsed) || (parsed as { v?: unknown }).v !== 1) return null; + return parsed as PersistedMrtState; + } catch { + return null; + } +}; + +const writePersistedMrtState = (key: string, state: PersistedMrtState) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, JSON.stringify(state)); + } catch { + // ignore because it's not critical + } +}; + export const useTranslatedMantineReactTable = ( - tableOptions: Omit, "localization">, + args: { id: string } & Omit, "localization">, ) => { + const { id, ...tableOptions } = args; const { locale } = useParams<{ locale: SupportedLanguage }>(); - const { data: mantineReactTable } = useSuspenseQuery({ + const { data: mantineReactTable } = useQuery({ queryKey: ["mantine-react-table-locale", locale], // eslint-disable-next-line no-restricted-syntax queryFn: async () => { return await localeConfigurations[locale].importMrtLocalization(); }, + staleTime: Number.POSITIVE_INFINITY, }); - return useMantineReactTable({ + const storageKey = useMemo(() => `${storagePrefix}:${id}`, [id]); + const persistedRef = useRef({ v: 1 }); + const [isHydrated, setIsHydrated] = useState(false); + + const callerState = (tableOptions.state ?? {}) as Record; + const isControlled = (key: PreferenceKey) => callerState[key] !== undefined; + + const managed = { + columnVisibility: !isControlled("columnVisibility"), + columnOrder: !isControlled("columnOrder"), + columnSizing: !isControlled("columnSizing"), + columnPinning: !isControlled("columnPinning"), + density: !isControlled("density"), + pagination: !isControlled("pagination"), + sorting: !isControlled("sorting"), + globalFilter: !isControlled("globalFilter"), + columnFilters: !isControlled("columnFilters"), + }; + + const [preferences, dispatch] = useReducer( + preferencesReducer, + tableOptions.initialState, + (initialState): PreferencesState => ({ + columnVisibility: pickInitial(undefined, initialState?.columnVisibility, {}), + columnOrder: pickInitial(undefined, initialState?.columnOrder, []), + columnSizing: pickInitial(undefined, initialState?.columnSizing, {}), + columnPinning: pickInitial(undefined, initialState?.columnPinning, { left: [], right: [] }), + density: pickInitial(undefined, initialState?.density, "md"), + pagination: pickInitial(undefined, initialState?.pagination, { pageIndex: 0, pageSize: 10 }), + sorting: pickInitial(undefined, initialState?.sorting, []), + globalFilter: pickInitial(undefined, initialState?.globalFilter as string | undefined, ""), + columnFilters: pickInitial(undefined, initialState?.columnFilters, []), + }), + ); + + useIsomorphicLayoutEffect(() => { + const persisted = readPersistedMrtState(storageKey); + if (persisted) { + persistedRef.current = persisted; + + const values: Partial = {}; + if (managed.columnVisibility && persisted.columnVisibility !== undefined) values.columnVisibility = persisted.columnVisibility; + if (managed.columnOrder && persisted.columnOrder !== undefined) values.columnOrder = persisted.columnOrder; + if (managed.columnSizing && persisted.columnSizing !== undefined) values.columnSizing = persisted.columnSizing; + if (managed.columnPinning && persisted.columnPinning !== undefined) values.columnPinning = persisted.columnPinning; + if (managed.density && persisted.density !== undefined) values.density = persisted.density; + if (managed.pagination && persisted.pagination !== undefined) values.pagination = persisted.pagination; + if (managed.sorting && persisted.sorting !== undefined) values.sorting = persisted.sorting; + if (managed.globalFilter && persisted.globalFilter !== undefined) values.globalFilter = persisted.globalFilter; + if (managed.columnFilters && persisted.columnFilters !== undefined) values.columnFilters = persisted.columnFilters; + + if (Object.keys(values).length > 0) { + dispatch({ type: "hydrate", values }); + } + } + setIsHydrated(true); + }, [ + storageKey, + managed.columnVisibility, + managed.columnOrder, + managed.columnSizing, + managed.columnPinning, + managed.density, + managed.pagination, + managed.sorting, + managed.globalFilter, + managed.columnFilters, + ]); + + useEffect(() => { + if (!isHydrated) return; + const persisted = persistedRef.current; + const next: PersistedMrtState = { v: 1 }; + + if (managed.columnVisibility) next.columnVisibility = preferences.columnVisibility; + else if (persisted.columnVisibility !== undefined) next.columnVisibility = persisted.columnVisibility; + + if (managed.columnOrder) next.columnOrder = preferences.columnOrder; + else if (persisted.columnOrder !== undefined) next.columnOrder = persisted.columnOrder; + + if (managed.columnSizing) next.columnSizing = preferences.columnSizing; + else if (persisted.columnSizing !== undefined) next.columnSizing = persisted.columnSizing; + + if (managed.columnPinning) next.columnPinning = preferences.columnPinning; + else if (persisted.columnPinning !== undefined) next.columnPinning = persisted.columnPinning; + + if (managed.density) next.density = preferences.density; + else if (persisted.density !== undefined) next.density = persisted.density; + + if (managed.pagination) next.pagination = preferences.pagination; + else if (persisted.pagination !== undefined) next.pagination = persisted.pagination; + + if (managed.sorting) next.sorting = preferences.sorting; + else if (persisted.sorting !== undefined) next.sorting = persisted.sorting; + + if (managed.globalFilter) next.globalFilter = preferences.globalFilter; + else if (persisted.globalFilter !== undefined) next.globalFilter = persisted.globalFilter; + + if (managed.columnFilters) next.columnFilters = preferences.columnFilters; + else if (persisted.columnFilters !== undefined) next.columnFilters = persisted.columnFilters; + + writePersistedMrtState(storageKey, next); + persistedRef.current = next; + }, [ + storageKey, + isHydrated, + managed.columnVisibility, + managed.columnOrder, + managed.columnSizing, + managed.columnPinning, + managed.density, + managed.pagination, + managed.sorting, + managed.globalFilter, + managed.columnFilters, + preferences, + ]); + + const mergedState = { ...(tableOptions.state ?? {}) } as Record; + if (managed.columnVisibility) mergedState.columnVisibility = preferences.columnVisibility; + if (managed.columnOrder) mergedState.columnOrder = preferences.columnOrder; + if (managed.columnSizing) mergedState.columnSizing = preferences.columnSizing; + if (managed.columnPinning) mergedState.columnPinning = preferences.columnPinning; + if (managed.density) mergedState.density = preferences.density; + if (managed.pagination) mergedState.pagination = preferences.pagination; + if (managed.sorting) mergedState.sorting = preferences.sorting; + if (managed.globalFilter) mergedState.globalFilter = preferences.globalFilter; + if (managed.columnFilters) mergedState.columnFilters = preferences.columnFilters; + + let onColumnVisibilityChange = tableOptions.onColumnVisibilityChange; + if (managed.columnVisibility) { + onColumnVisibilityChange = (updater) => { + dispatch({ + type: "set", + key: "columnVisibility", + value: applyUpdater(updater, preferences.columnVisibility), + }); + }; + } + + let onColumnOrderChange = tableOptions.onColumnOrderChange; + if (managed.columnOrder) { + onColumnOrderChange = (updater) => { + dispatch({ type: "set", key: "columnOrder", value: applyUpdater(updater, preferences.columnOrder) }); + }; + } + + let onColumnSizingChange = tableOptions.onColumnSizingChange; + if (managed.columnSizing) { + onColumnSizingChange = (updater) => { + dispatch({ type: "set", key: "columnSizing", value: applyUpdater(updater, preferences.columnSizing) }); + }; + } + + let onColumnPinningChange = tableOptions.onColumnPinningChange; + if (managed.columnPinning) { + onColumnPinningChange = (updater) => { + dispatch({ type: "set", key: "columnPinning", value: applyUpdater(updater, preferences.columnPinning) }); + }; + } + + let onDensityChange = tableOptions.onDensityChange; + if (managed.density) { + onDensityChange = (updater) => { + dispatch({ type: "set", key: "density", value: applyUpdater(updater, preferences.density) }); + }; + } + + let onPaginationChange = tableOptions.onPaginationChange; + if (managed.pagination) { + onPaginationChange = (updater) => { + dispatch({ type: "set", key: "pagination", value: applyUpdater(updater, preferences.pagination) }); + }; + } + + let onSortingChange = tableOptions.onSortingChange; + if (managed.sorting) { + onSortingChange = (updater) => { + dispatch({ type: "set", key: "sorting", value: applyUpdater(updater, preferences.sorting) }); + }; + } + + let onGlobalFilterChange = tableOptions.onGlobalFilterChange; + if (managed.globalFilter) { + onGlobalFilterChange = (updater) => { + dispatch({ type: "set", key: "globalFilter", value: applyUpdater(updater, preferences.globalFilter) }); + }; + } + + let onColumnFiltersChange = tableOptions.onColumnFiltersChange; + if (managed.columnFilters) { + onColumnFiltersChange = (updater) => { + dispatch({ type: "set", key: "columnFilters", value: applyUpdater(updater, preferences.columnFilters) }); + }; + } + + const options: MRT_TableOptions = { ...tableOptions, - localization: mantineReactTable, - }); + state: mergedState, + onColumnVisibilityChange, + onColumnOrderChange, + onColumnSizingChange, + onColumnPinningChange, + onDensityChange, + onPaginationChange, + onSortingChange, + onGlobalFilterChange, + onColumnFiltersChange, + }; + + if (mantineReactTable) { + options.localization = mantineReactTable; + } + + return useMantineReactTable(options); }; diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 869b76f0bb..fa54a72332 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -205,6 +205,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo const columns = useMemo(() => createColumns(t), [t]); const table = useTranslatedMantineReactTable({ + id: "widget-docker", columns, data: containers, enablePagination: false, diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 9b540339e1..4f29fa9722 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -126,6 +126,7 @@ export default function MediaServerWidget({ const { openModal } = useModalAction(ItemInfoModal); const table = useTranslatedMantineReactTable({ + id: "widget-media-server", columns, data: flatSessions, enablePagination: false,