Files
openmaic/OpenMAIC/lib/store/user-profile.ts
2026-08-16 14:58:47 +08:00

77 lines
2.6 KiB
TypeScript

/**
* User Profile Store
*
* Persists avatar, nickname & bio through the `@openmaic/storage` KVStore in
* the `account` scope: this is the learner's own identity, exactly the data a
* server-backed deployment is expected to carry across their devices.
*
* The profile is not user-editable in the UI anymore — it is read-only display
* data that a server API is expected to populate.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createKVPersistStorage, purgeLegacyPersistKey } from '@/lib/store/kv-persist';
/**
* Bound after the store exists; see `onWriteRefused` for why it is not inlined.
* The explicit annotation is what breaks the type cycle — inferring this from
* the store would put the store back in its own definition.
*/
const recovery: { rehydrate?: () => void | Promise<void> } = {};
/** Predefined avatar options */
export const AVATAR_OPTIONS = [
'/avatars/user.png',
'/avatars/teacher-2.png',
'/avatars/assist-2.png',
'/avatars/clown-2.png',
'/avatars/curious-2.png',
'/avatars/note-taker-2.png',
'/avatars/thinker-2.png',
] as const;
export interface UserProfileState {
/** Local avatar path or data-URL (for custom uploads) */
avatar: string;
nickname: string;
bio: string;
setAvatar: (avatar: string) => void;
setNickname: (nickname: string) => void;
setBio: (bio: string) => void;
}
export const useUserProfileStore = create<UserProfileState>()(
persist(
(set) => ({
avatar: AVATAR_OPTIONS[0],
nickname: '',
bio: '',
setAvatar: (avatar) => set({ avatar }),
setNickname: (nickname) => set({ nickname }),
setBio: (bio) => set({ bio }),
}),
{
name: 'user-profile-storage',
storage: createKVPersistStorage<UserProfileState>('account', {
// One recovery attempt when a write is refused because hydration never
// succeeded — the backend may have come back since. Routed through a
// variable assigned below rather than naming the store directly: a
// self-reference here would make the store's own type circular and
// silently widen every selector to `any`.
onWriteRefused: () => recovery.rehydrate?.(),
}),
},
),
);
// Bound after the store exists so the `onWriteRefused` hook above stays free of
// a self-reference (see the comment there).
recovery.rehydrate = () => useUserProfileStore.persist.rehydrate();
// Best-effort, fire-and-forget: drop the pre-cutover raw `localStorage` blob.
// It is never read (this store does not migrate legacy data), so a leftover is
// only garbage. No correctness depends on it.
purgeLegacyPersistKey('user-profile-storage');