From f7ea11eab9325bcb033a15acfc1e6e61922f7a7c Mon Sep 17 00:00:00 2001 From: Sebastian Pietras Date: Thu, 30 Jul 2026 23:14:08 +0200 Subject: [PATCH] Added history tracking --- src/src/app/(root)/layout.tsx.jinja | 51 ++++++++++--------- .../common/localization/locales/en.po.jinja | 4 +- .../common/localization/locales/pl.po.jinja | 4 +- .../components/history-synchronizer/index.ts | 2 + .../components/history-synchronizer/main.tsx | 36 +++++++++++++ .../components/history-synchronizer/types.ts | 1 + .../components/history-provider/index.ts | 2 + .../components/history-provider/main.tsx | 14 +++++ .../components/history-provider/types.ts | 3 ++ .../generic/hooks/use-history/index.ts | 8 +++ .../generic/hooks/use-history/main.ts | 13 +++++ .../generic/hooks/use-history/types.ts | 9 ++++ .../metadata/components/metadata/main.tsx | 8 +-- .../state/components/state-provider/utils.ts | 2 +- src/src/isomorphic/state/types.ts | 10 ++++ 15 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/index.ts create mode 100644 src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/main.tsx create mode 100644 src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/types.ts create mode 100644 src/src/isomorphic/generic/components/history-provider/index.ts create mode 100644 src/src/isomorphic/generic/components/history-provider/main.tsx create mode 100644 src/src/isomorphic/generic/components/history-provider/types.ts create mode 100644 src/src/isomorphic/generic/hooks/use-history/index.ts create mode 100644 src/src/isomorphic/generic/hooks/use-history/main.ts create mode 100644 src/src/isomorphic/generic/hooks/use-history/types.ts diff --git a/src/src/app/(root)/layout.tsx.jinja b/src/src/app/(root)/layout.tsx.jinja index 4be80d5..58c750a 100644 --- a/src/src/app/(root)/layout.tsx.jinja +++ b/src/src/app/(root)/layout.tsx.jinja @@ -14,6 +14,7 @@ import type { Schemas } from "./schemas"; import type { Keys } from "./types"; import { ThemeScript } from "../../common/theme/components/theme-script"; +import { HistoryProvider } from "../../isomorphic/generic/components/history-provider"; {%- if identity %} import { IdentityProvider } from "../../isomorphic/identity/components/identity-provider"; {%- endif %} @@ -97,35 +98,37 @@ export default async function RootLayout({ - - - - - - {%- if identity %} - + + + + + + + {%- if identity %} + + + {children} + + {%- else %} {children} - - {%- else %} - - {children} - {%- endif %} - - - - - + {%- endif %} + + + + + + diff --git a/src/src/common/localization/locales/en.po.jinja b/src/src/common/localization/locales/en.po.jinja index abd2ef0..1a4c360 100644 --- a/src/src/common/localization/locales/en.po.jinja +++ b/src/src/common/localization/locales/en.po.jinja @@ -29,12 +29,12 @@ msgstr "Error" msgid "Error • {{ appname }}" msgstr "Error • {{ appname }}" -#: src/app/(root)/layout.tsx:{% if identity %}36{% else %}34{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}37{% else %}35{% endif %} msgid "{{ description }}" msgstr "{{ description }}" #: src/app/(root)/(main)/(home)/page.tsx:{% if identity and access %}21{% else %}20{% endif %} -#: src/app/(root)/layout.tsx:{% if identity %}42{% else %}40{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}43{% else %}41{% endif %} msgid "{{ appname }}" msgstr "{{ appname }}" {%- if identity and access %} diff --git a/src/src/common/localization/locales/pl.po.jinja b/src/src/common/localization/locales/pl.po.jinja index 0673c26..f05f335 100644 --- a/src/src/common/localization/locales/pl.po.jinja +++ b/src/src/common/localization/locales/pl.po.jinja @@ -29,12 +29,12 @@ msgstr "Błąd" msgid "Error • {{ appname }}" msgstr "Błąd • {{ appname }}" -#: src/app/(root)/layout.tsx:{% if identity %}36{% else %}34{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}37{% else %}35{% endif %} msgid "{{ description }}" msgstr "{{ description }}" #: src/app/(root)/(main)/(home)/page.tsx:{% if identity and access %}21{% else %}20{% endif %} -#: src/app/(root)/layout.tsx:{% if identity %}42{% else %}40{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}43{% else %}41{% endif %} msgid "{{ appname }}" msgstr "{{ appname }}" {%- if identity and access %} diff --git a/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/index.ts b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/index.ts new file mode 100644 index 0000000..d8c7504 --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/index.ts @@ -0,0 +1,2 @@ +export { HistorySynchronizer } from "./main"; +export type { HistorySynchronizerInput } from "./types"; diff --git a/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/main.tsx b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/main.tsx new file mode 100644 index 0000000..749d419 --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/main.tsx @@ -0,0 +1,36 @@ +import { isEqual } from "es-toolkit/predicate"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import type { HistorySynchronizerInput } from "./types"; + +import { useGlobalState } from "../../../../../state/hooks/use-global-state"; + +export function HistorySynchronizer({}: HistorySynchronizerInput) { + const { state } = useGlobalState(); + + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + const latestEntry = state.current.history.entries.at(-1); + const newEntry = { + path: pathname, + query: searchParams.entries().reduce( + (acc, [key, value]) => { + if (acc[key] === undefined) acc[key] = value; + else if (Array.isArray(acc[key])) acc[key].push(value); + else acc[key] = [acc[key], value]; + + return acc; + }, + {} as { [key: string]: string | string[] }, + ), + }; + + if (!isEqual(newEntry, latestEntry)) + state.current.history.entries.push(newEntry); + }, [pathname, searchParams, state.current.history.entries]); + + return null; +} diff --git a/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/types.ts b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/types.ts new file mode 100644 index 0000000..9f2e156 --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/components/history-synchronizer/types.ts @@ -0,0 +1 @@ +export type HistorySynchronizerInput = object; diff --git a/src/src/isomorphic/generic/components/history-provider/index.ts b/src/src/isomorphic/generic/components/history-provider/index.ts new file mode 100644 index 0000000..c7d75cf --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/index.ts @@ -0,0 +1,2 @@ +export { HistoryProvider } from "./main"; +export type { HistoryProviderInput } from "./types"; diff --git a/src/src/isomorphic/generic/components/history-provider/main.tsx b/src/src/isomorphic/generic/components/history-provider/main.tsx new file mode 100644 index 0000000..fd81c97 --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/main.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { HistoryProviderInput } from "./types"; + +import { HistorySynchronizer } from "./components/history-synchronizer"; + +export function HistoryProvider({ children }: HistoryProviderInput) { + return ( + <> + + {children} + + ); +} diff --git a/src/src/isomorphic/generic/components/history-provider/types.ts b/src/src/isomorphic/generic/components/history-provider/types.ts new file mode 100644 index 0000000..d39f61a --- /dev/null +++ b/src/src/isomorphic/generic/components/history-provider/types.ts @@ -0,0 +1,3 @@ +import type { PropsWithChildren } from "react"; + +export type HistoryProviderInput = PropsWithChildren; diff --git a/src/src/isomorphic/generic/hooks/use-history/index.ts b/src/src/isomorphic/generic/hooks/use-history/index.ts new file mode 100644 index 0000000..eb1de81 --- /dev/null +++ b/src/src/isomorphic/generic/hooks/use-history/index.ts @@ -0,0 +1,8 @@ +import "client-only"; + +export { useHistory } from "./main"; +export type { + UseHistoryHistory, + UseHistoryInput, + UseHistoryOutput, +} from "./types"; diff --git a/src/src/isomorphic/generic/hooks/use-history/main.ts b/src/src/isomorphic/generic/hooks/use-history/main.ts new file mode 100644 index 0000000..6598777 --- /dev/null +++ b/src/src/isomorphic/generic/hooks/use-history/main.ts @@ -0,0 +1,13 @@ +import { useMemo } from "react"; + +import type { UseHistoryInput, UseHistoryOutput } from "./types"; + +import { useGlobalState } from "../../../state/hooks/use-global-state"; + +export function useHistory({}: UseHistoryInput = {}): UseHistoryOutput { + const { state } = useGlobalState(); + + const history = state.snapshot.history; + + return useMemo(() => ({ history: history }), [history]); +} diff --git a/src/src/isomorphic/generic/hooks/use-history/types.ts b/src/src/isomorphic/generic/hooks/use-history/types.ts new file mode 100644 index 0000000..db891db --- /dev/null +++ b/src/src/isomorphic/generic/hooks/use-history/types.ts @@ -0,0 +1,9 @@ +import type { ReadonlyState } from "../../../state/types"; + +export type UseHistoryHistory = ReadonlyState["history"]; + +export type UseHistoryInput = object; + +export type UseHistoryOutput = { + history: UseHistoryHistory; +}; diff --git a/src/src/isomorphic/metadata/components/metadata/main.tsx b/src/src/isomorphic/metadata/components/metadata/main.tsx index 1ad5a27..24f12dd 100644 --- a/src/src/isomorphic/metadata/components/metadata/main.tsx +++ b/src/src/isomorphic/metadata/components/metadata/main.tsx @@ -5,6 +5,7 @@ import { useDeepCompareMemo } from "use-deep-compare"; import type { MetadataInput } from "./types"; +import { useHistory } from "../../../generic/hooks/use-history"; import { useLocalization } from "../../../localization/hooks/use-localization"; import { serializeViewportAttributes } from "./utils"; @@ -15,6 +16,7 @@ export function Metadata({ title, viewportAttributes, }: MetadataInput) { + const { history } = useHistory(); const { localization } = useLocalization(); const cachedDescription = useDeepCompareMemo( @@ -24,7 +26,7 @@ export function Metadata({ typeof description === "string" ? description : localization.localize(description), - [description, localization.localize], + [description, history.entries.length, localization.localize], ); const cachedViewport = useDeepCompareMemo( @@ -32,7 +34,7 @@ export function Metadata({ viewportAttributes === undefined ? viewportAttributes : serializeViewportAttributes(viewportAttributes), - [viewportAttributes], + [history.entries.length, viewportAttributes], ); const cachedTitle = useDeepCompareMemo( @@ -40,7 +42,7 @@ export function Metadata({ title === undefined || title === null || typeof title === "string" ? title : localization.localize(title), - [title, localization.localize], + [history.entries.length, localization.localize, title], ); return ( diff --git a/src/src/isomorphic/state/components/state-provider/utils.ts b/src/src/isomorphic/state/components/state-provider/utils.ts index 232bc82..c567f6a 100644 --- a/src/src/isomorphic/state/components/state-provider/utils.ts +++ b/src/src/isomorphic/state/components/state-provider/utils.ts @@ -3,5 +3,5 @@ import { proxy } from "valtio"; import type { State } from "../../types"; export function createInitialState() { - return proxy({} satisfies State); + return proxy({ history: { entries: [] } } satisfies State); } diff --git a/src/src/isomorphic/state/types.ts b/src/src/isomorphic/state/types.ts index 0d24032..ddf5e7a 100644 --- a/src/src/isomorphic/state/types.ts +++ b/src/src/isomorphic/state/types.ts @@ -2,6 +2,15 @@ import "client-only"; import type { ReadonlyDeep } from "type-fest"; +export type HistoryEntry = { + path: string; + query: { [key: string]: string | string[] }; +}; + +export type HistoryState = { + entries: HistoryEntry[]; +}; + export type NowState = { counter: number; timer: number; @@ -9,6 +18,7 @@ export type NowState = { }; export type State = { + history: HistoryState; now?: NowState; };