Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

111 lines
4.4 KiB
TypeScript

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import type { SidebarState } from './types'
/**
* Clamps an expanded sidebar width into the valid range for the current
* viewport. The upper bound can never drop below {@link SIDEBAR_WIDTH.MIN}, so a
* narrow window (where `innerWidth * MAX_PERCENTAGE < MIN`) still yields a width
* at or above the minimum instead of collapsing the sidebar to nothing.
*/
function clampSidebarWidth(width: number): number {
if (!Number.isFinite(width)) return SIDEBAR_WIDTH.DEFAULT
const max =
typeof window === 'undefined'
? Number.POSITIVE_INFINITY
: Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
return Math.min(Math.max(width, SIDEBAR_WIDTH.MIN), max)
}
function applySidebarWidth(width: number) {
if (typeof window === 'undefined') return
const value = Number.isFinite(width) ? width : SIDEBAR_WIDTH.DEFAULT
document.documentElement.style.setProperty('--sidebar-width', `${value}px`)
}
/**
* The `sidebar_collapsed` cookie is the single source of truth for collapse: the
* server layout reads it to render the correct structure on the first paint
* (it can't read `localStorage`), and the client seeds its initial state from it
* below. Width is the only field persisted to `localStorage`.
*/
function applyCollapsedCookie(collapsed: boolean) {
if (typeof document === 'undefined') return
document.cookie = `sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
}
/** Reads the collapse state the server saw, so the client store seeds identically. Matches the
* cookie value strictly (`=1`) so `sidebar_collapsed=10` and the like aren't read as collapsed. */
export function readCollapsedCookie(): boolean {
if (typeof document === 'undefined') return false
return document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/)?.[1] === '1'
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set, get) => ({
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: readCollapsedCookie(),
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
if (get().isCollapsed) return
const clampedWidth = clampSidebarWidth(width)
set({ sidebarWidth: clampedWidth })
applySidebarWidth(clampedWidth)
},
toggleCollapsed: () => {
const { isCollapsed, sidebarWidth } = get()
const nextCollapsed = !isCollapsed
set({ isCollapsed: nextCollapsed })
applyCollapsedCookie(nextCollapsed)
applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth))
},
syncWidth: () => {
const { isCollapsed, sidebarWidth } = get()
if (isCollapsed) {
applySidebarWidth(SIDEBAR_WIDTH.COLLAPSED)
return
}
const clampedWidth = clampSidebarWidth(sidebarWidth)
if (clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth })
applySidebarWidth(clampedWidth)
},
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
}),
{
name: 'sidebar-state',
/**
* Width is hydrated manually from a client-only effect (see Sidebar) so
* `_hasHydrated` is deterministically `false` during SSR and the first
* client render — both of which read collapse from the cookie-seeded prop.
* This is zustand's documented SSR pattern; it avoids relying on auto
* hydration's behavior when `localStorage` is absent on the server.
*/
skipHydration: true,
onRehydrateStorage: () => (state) => {
if (state) {
state.setHasHydrated(true)
const width = state.isCollapsed
? SIDEBAR_WIDTH.COLLAPSED
: clampSidebarWidth(state.sidebarWidth)
applySidebarWidth(width)
}
},
/** Only width is persisted; collapse lives in the cookie. */
partialize: (state) => ({ sidebarWidth: state.sidebarWidth }),
/**
* Never lets a legacy persisted `isCollapsed` override the cookie-seeded
* value — the cookie is the source of truth (handles migration cleanly).
*/
merge: (persisted, current) => ({
...current,
...(persisted as Partial<SidebarState>),
isCollapsed: current.isCollapsed,
}),
}
)
)