Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit b419ed9

Browse files
committed
refactor(admin): migrate ad-hoc stores to Zustand + Jotai
Adopts lobe-chat's slice pattern (externalized action class, flattenActions, subscribeWithSelector + shallow). Each store gets initial-state / action / store / index split into its own folder. - focus-scope, context-menu, modal-imperative, codemirror/editor-store → Zustand, with imperative wrappers preserved so callers like present.ts and the ContextMenu trigger keep their existing API. - theme, codemirror/editor-setting → Zustand + persist middleware. The hand-rolled localStorage + custom-event broadcasts are gone; the matchMedia listener in theme.ts stays on useSyncExternalStore (DOM source). - codemirror/image-popover-state → Jotai atom (small, transient). - Add src/store/{types,utils/flatten-actions}.ts shared by every slice. useSyncExternalStore now only remains in theme.ts for matchMedia.
1 parent eff042b commit b419ed9

41 files changed

Lines changed: 1050 additions & 430 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"path-browserify": "1.0.1",
9393
"qier-progress": "1.0.4",
9494
"qs": "6.15.0",
95+
"jotai": "2.20.0",
9596
"react": "19.2.4",
9697
"react-dom": "19.2.4",
9798
"react-resizable-panels": "4.11.1",
@@ -102,7 +103,8 @@
102103
"tinykeys": "4.0.0",
103104
"validator": "13.15.26",
104105
"xss": "1.0.15",
105-
"zod": "4.3.6"
106+
"zod": "4.3.6",
107+
"zustand": "5.0.13"
106108
},
107109
"devDependencies": {
108110
"@babel/core": "7.29.0",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { StoreSetter } from '~/store/types'
2+
import type { ThemeMode } from './initial-state'
3+
import type { ThemeStore } from './store'
4+
5+
type Setter = StoreSetter<ThemeStore>
6+
7+
export class ThemeActionImpl {
8+
readonly #get: () => ThemeStore
9+
readonly #set: Setter
10+
11+
constructor(set: Setter, get: () => ThemeStore, _api?: unknown) {
12+
void _api
13+
this.#set = set
14+
this.#get = get
15+
}
16+
17+
setThemeMode = (themeMode: ThemeMode): void => {
18+
if (this.#get().themeMode === themeMode) return
19+
this.#set({ themeMode }, false, 'theme/setThemeMode')
20+
}
21+
}
22+
23+
export const createThemeSlice = (
24+
set: Setter,
25+
get: () => ThemeStore,
26+
api: unknown,
27+
) => new ThemeActionImpl(set, get, api)
28+
29+
export type ThemeAction = Pick<ThemeActionImpl, keyof ThemeActionImpl>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type { ThemeAction } from './action'
2+
export type { ThemeMode, ThemeState } from './initial-state'
3+
export type { ThemeStore } from './store'
4+
export { getThemeStoreState, useThemeStore } from './store'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type ThemeMode = 'dark' | 'light' | 'system'
2+
3+
export interface ThemeState {
4+
themeMode: ThemeMode
5+
}
6+
7+
export const initialThemeState: ThemeState = {
8+
themeMode: 'system',
9+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { persist, subscribeWithSelector } from 'zustand/middleware'
2+
import { shallow } from 'zustand/shallow'
3+
import { createWithEqualityFn } from 'zustand/traditional'
4+
import type { StateCreator } from 'zustand/vanilla'
5+
import type { ThemeAction } from './action'
6+
import type { ThemeMode, ThemeState } from './initial-state'
7+
8+
import { flattenActions } from '~/store/utils/flatten-actions'
9+
10+
import { createThemeSlice } from './action'
11+
import { initialThemeState } from './initial-state'
12+
13+
export interface ThemeStore extends ThemeState, ThemeAction {}
14+
15+
const STORAGE_KEY = 'theme-mode'
16+
17+
const isValidMode = (value: unknown): value is ThemeMode =>
18+
value === 'dark' || value === 'light' || value === 'system'
19+
20+
const createStore: StateCreator<ThemeStore> = (...params) => ({
21+
...initialThemeState,
22+
...flattenActions<ThemeAction>([createThemeSlice(...params)]),
23+
})
24+
25+
export const useThemeStore = createWithEqualityFn<ThemeStore>()(
26+
subscribeWithSelector(
27+
persist(createStore, {
28+
name: STORAGE_KEY,
29+
partialize: (state) => ({ themeMode: state.themeMode }),
30+
// Legacy storage layout: just the raw mode string (no JSON envelope).
31+
// Read it on load and write back via the modern envelope on first set.
32+
storage: {
33+
getItem: (name) => {
34+
if (typeof window === 'undefined') return null
35+
const raw = window.localStorage.getItem(name)
36+
if (!raw) return null
37+
const stripped = raw.replace(/^"|"$/g, '')
38+
if (isValidMode(stripped)) {
39+
return { state: { themeMode: stripped }, version: 0 }
40+
}
41+
try {
42+
return JSON.parse(raw)
43+
} catch {
44+
return null
45+
}
46+
},
47+
setItem: (name, value) => {
48+
if (typeof window === 'undefined') return
49+
// Persist `system` as removal so the storage event mirrors the
50+
// prior contract — listeners don't need to special-case it.
51+
if (value.state.themeMode === 'system') {
52+
window.localStorage.removeItem(name)
53+
} else {
54+
window.localStorage.setItem(name, JSON.stringify(value))
55+
}
56+
},
57+
removeItem: (name) => {
58+
if (typeof window === 'undefined') return
59+
window.localStorage.removeItem(name)
60+
},
61+
},
62+
}),
63+
),
64+
shallow,
65+
)
66+
67+
export const getThemeStoreState = () => useThemeStore.getState()

apps/admin/src/store/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Zustand setter signature mirroring the `set` parameter passed to the
3+
* store creator. Includes the optional third action-name argument for
4+
* the `devtools` middleware.
5+
*
6+
* Cribbed from lobe-chat's `src/store/types.ts` so the action-class
7+
* pattern (`this.#set({...}, false, 'actionName')`) carries cleanly.
8+
*/
9+
export interface StoreSetter<TStore> {
10+
(
11+
partial:
12+
| TStore
13+
| Partial<TStore>
14+
| ((state: TStore) => TStore | Partial<TStore>),
15+
replace?: false | undefined,
16+
action?: unknown,
17+
): void
18+
(
19+
state: TStore | ((state: TStore) => TStore),
20+
replace: true,
21+
action?: unknown,
22+
): void
23+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Flatten one or more action-class instances into a single object whose
3+
* methods are bound to their owning instance. Used when assembling a
4+
* Zustand store from slice classes — class instances can't be spread
5+
* cleanly because methods live on the prototype.
6+
*
7+
* Each method is bound to the original instance so `this` resolves
8+
* correctly when callers invoke `store.someAction()`.
9+
*
10+
* Cribbed from lobe-chat's `src/store/utils/flattenActions.ts`.
11+
*/
12+
export function flattenActions<T extends object>(actions: object[]): T {
13+
const result = {} as T
14+
15+
for (const action of actions) {
16+
let current: object | null = action
17+
while (current && current !== Object.prototype) {
18+
const keys = Object.getOwnPropertyNames(current)
19+
20+
for (const key of keys) {
21+
if (key === 'constructor') continue
22+
if (key in result) continue
23+
24+
const descriptor = Object.getOwnPropertyDescriptor(current, key)
25+
if (!descriptor) continue
26+
27+
if (typeof descriptor.value === 'function') {
28+
;(result as Record<string, unknown>)[key] =
29+
descriptor.value.bind(action)
30+
} else {
31+
Object.defineProperty(result, key, {
32+
...descriptor,
33+
configurable: true,
34+
enumerable: true,
35+
})
36+
}
37+
}
38+
39+
current = Object.getPrototypeOf(current)
40+
}
41+
}
42+
43+
return result
44+
}

apps/admin/src/theme.ts

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,40 @@
11
import { useEffect, useMemo, useSyncExternalStore } from 'react'
2+
import type { ThemeMode } from '~/store/theme'
3+
4+
import { useThemeStore } from '~/store/theme'
25

36
export const themeColors = {
47
primary: '#1a9cf3',
58
primaryDeep: '#0f7ec4',
69
primaryShallow: '#4fb5f7',
710
} as const
811

9-
export type ThemeMode = 'dark' | 'light' | 'system'
12+
export type { ThemeMode }
1013

11-
const themeModeChangeEvent = 'mx-admin-theme-mode-change'
14+
const DARK_QUERY = '(prefers-color-scheme: dark)'
1215

16+
/**
17+
* Reads the persisted theme mode from the Zustand store and resolves it
18+
* against the OS preference. The OS-preference side still goes through
19+
* `useSyncExternalStore` because the source is a `MediaQueryList`, not
20+
* React state.
21+
*/
1322
export function useThemeMode() {
14-
const query = useMemo(
15-
() => window.matchMedia('(prefers-color-scheme: dark)'),
16-
[],
17-
)
18-
19-
const snapshot = useSyncExternalStore(
20-
(onStoreChange) => {
21-
query.addEventListener('change', onStoreChange)
22-
window.addEventListener(themeModeChangeEvent, onStoreChange)
23-
24-
return () => {
25-
query.removeEventListener('change', onStoreChange)
26-
window.removeEventListener(themeModeChangeEvent, onStoreChange)
27-
}
23+
const themeMode = useThemeStore((s) => s.themeMode)
24+
const setThemeMode = useThemeStore((s) => s.setThemeMode)
25+
26+
const query = useMemo(() => window.matchMedia(DARK_QUERY), [])
27+
const systemMatches = useSyncExternalStore(
28+
(onChange) => {
29+
query.addEventListener('change', onChange)
30+
return () => query.removeEventListener('change', onChange)
2831
},
29-
() => getThemeSnapshot(query),
30-
() => 'system:light',
32+
() => query.matches,
33+
() => false,
3134
)
32-
const [themeMode, resolvedTheme] = snapshot.split(':') as [
33-
ThemeMode,
34-
'dark' | 'light',
35-
]
35+
36+
const resolvedTheme: 'dark' | 'light' =
37+
themeMode === 'system' ? (systemMatches ? 'dark' : 'light') : themeMode
3638
const isDark = resolvedTheme === 'dark'
3739

3840
useEffect(() => {
@@ -57,28 +59,7 @@ export function installThemeTokens() {
5759
)
5860
}
5961

62+
/** Imperative setter for callers outside React. */
6063
export function setThemeMode(themeMode: ThemeMode) {
61-
if (themeMode === 'system') {
62-
localStorage.removeItem('theme-mode')
63-
} else {
64-
localStorage.setItem('theme-mode', themeMode)
65-
}
66-
67-
window.dispatchEvent(new Event(themeModeChangeEvent))
68-
}
69-
70-
function getThemeSnapshot(query: MediaQueryList) {
71-
const themeMode = readThemeMode()
72-
const resolvedTheme =
73-
themeMode === 'system' ? (query.matches ? 'dark' : 'light') : themeMode
74-
75-
return `${themeMode}:${resolvedTheme}` as const
76-
}
77-
78-
function readThemeMode(): ThemeMode {
79-
const storedTheme = localStorage.getItem('theme-mode')?.replace(/^"|"$/g, '')
80-
81-
if (storedTheme === 'dark' || storedTheme === 'light') return storedTheme
82-
83-
return 'system'
64+
useThemeStore.getState().setThemeMode(themeMode)
8465
}

apps/admin/src/ui/feedback/modal-imperative/root.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { useSyncExternalStore } from 'react'
2-
31
import { Modal } from '~/ui/feedback/modal'
42
import { PortalLayerScope } from '~/ui/feedback/portal-layer'
53

64
import { ModalInstanceProvider } from './context'
7-
import { modalStore } from './store'
5+
import { modalStore, useModalStore } from './store'
86

97
/**
108
* Z-stack stride: each modal reserves this many depth steps so its descendants
@@ -14,11 +12,7 @@ import { modalStore } from './store'
1412
const Z_STACK_STRIDE = 10
1513

1614
export function ModalRoot() {
17-
const stack = useSyncExternalStore(
18-
modalStore.subscribe,
19-
modalStore.getSnapshot,
20-
modalStore.getSnapshot,
21-
)
15+
const stack = useModalStore((s) => s.stack)
2216
const topIndex = stack.length - 1
2317

2418
return (

apps/admin/src/ui/feedback/modal-imperative/store.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.

0 commit comments

Comments
 (0)