feat(web): add resizable sidebar to skill page with localStorage persistence

This commit is contained in:
yyh 2026-01-28 14:36:00 +08:00
parent 190453d397
commit a0526143e2
No known key found for this signature in database
4 changed files with 162 additions and 1 deletions

View File

@ -27,3 +27,7 @@ export const NODE_MENU_TYPE = {
} as const
export type NodeMenuType = (typeof NODE_MENU_TYPE)[keyof typeof NODE_MENU_TYPE]
export const SIDEBAR_MIN_WIDTH = 240
export const SIDEBAR_MAX_WIDTH = 480
export const SIDEBAR_DEFAULT_WIDTH = 320

View File

@ -1,12 +1,47 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from './constants'
type SidebarProps = PropsWithChildren
const Sidebar: FC<SidebarProps> = ({ children }) => {
const { run: persistWidth } = useDebounceFn(
(width: number) => storage.set(STORAGE_KEYS.SKILL.SIDEBAR_WIDTH, width),
{ wait: 200 },
)
const handleResize = useCallback((width: number) => {
persistWidth(width)
}, [persistWidth])
const { triggerRef, containerRef } = useResizePanel({
direction: 'horizontal',
triggerDirection: 'right',
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
onResize: handleResize,
})
return (
<aside className="flex h-full w-[320px] shrink-0 flex-col gap-px overflow-hidden rounded-[10px] border border-components-panel-border-subtle bg-components-panel-bg">
<aside
ref={containerRef}
style={{ width: storage.getNumber(STORAGE_KEYS.SKILL.SIDEBAR_WIDTH, SIDEBAR_DEFAULT_WIDTH) }}
className="relative flex h-full shrink-0 flex-col gap-px overflow-hidden rounded-[10px] border border-components-panel-border-subtle bg-components-panel-bg"
>
{children}
<div
ref={triggerRef}
className="absolute -right-1 top-0 z-10 flex h-full w-2 cursor-col-resize items-center justify-center"
>
<div className="h-10 w-0.5 rounded-sm bg-state-base-handle transition-all hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid" />
</div>
</aside>
)
}

View File

@ -0,0 +1,5 @@
export const STORAGE_KEYS = {
SKILL: {
SIDEBAR_WIDTH: 'skill-sidebar-width',
},
} as const

117
web/utils/storage.ts Normal file
View File

@ -0,0 +1,117 @@
import { isClient } from './client'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
const STORAGE_VERSION = 'v1'
let _isAvailable: boolean | null = null
function isLocalStorageAvailable(): boolean {
if (_isAvailable !== null)
return _isAvailable
if (!isClient) {
_isAvailable = false
return false
}
try {
const testKey = '__storage_test__'
localStorage.setItem(testKey, 'test')
localStorage.removeItem(testKey)
_isAvailable = true
return true
}
catch {
_isAvailable = false
return false
}
}
function versionedKey(key: string): string {
return `${STORAGE_VERSION}:${key}`
}
function get<T extends JsonValue>(key: string, defaultValue?: T): T | null {
if (!isLocalStorageAvailable())
return defaultValue ?? null
try {
const item = localStorage.getItem(versionedKey(key))
if (item === null)
return defaultValue ?? null
try {
return JSON.parse(item) as T
}
catch {
return item as T
}
}
catch {
return defaultValue ?? null
}
}
function set<T extends JsonValue>(key: string, value: T): void {
if (!isLocalStorageAvailable())
return
try {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
localStorage.setItem(versionedKey(key), stringValue)
}
catch {
// Silent fail - localStorage may be full or disabled
}
}
function remove(key: string): void {
if (!isLocalStorageAvailable())
return
try {
localStorage.removeItem(versionedKey(key))
}
catch {
// Silent fail
}
}
function getNumber(key: string): number | null
function getNumber(key: string, defaultValue: number): number
function getNumber(key: string, defaultValue?: number): number | null {
const value = get<string | number>(key)
if (value === null)
return defaultValue ?? null
const parsed = typeof value === 'number' ? value : Number.parseFloat(value as string)
return Number.isNaN(parsed) ? (defaultValue ?? null) : parsed
}
function getBoolean(key: string): boolean | null
function getBoolean(key: string, defaultValue: boolean): boolean
function getBoolean(key: string, defaultValue?: boolean): boolean | null {
const value = get<string | boolean>(key)
if (value === null)
return defaultValue ?? null
if (typeof value === 'boolean')
return value
return value === 'true'
}
function resetCache(): void {
_isAvailable = null
}
export const storage = {
get,
set,
remove,
getNumber,
getBoolean,
isAvailable: isLocalStorageAvailable,
resetCache,
}