feat(settings): add settings page with live abacus preview
- Create /settings page with tabs for General, Abacus Style, and MCP Keys - Extract AbacusStylePanel as shared component for nav dropdown and settings - Add AbacusDock integration with auto-dock/undock on tab mount/unmount - Add hideUndock prop to prevent manual undocking in settings preview - Add updateDockConfig to update dock props without re-registration - Fix requestDock stability using ref to prevent animation on config changes - Add ThemeToggle back to AppNavBar while keeping it in settings - Add navSlot prop to PageWithNav for vision training contextual nav - Create VisionTrainingNavSlot for model selection in vision training pages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
922
apps/web/src/app/settings/page.tsx
Normal file
922
apps/web/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,922 @@
|
||||
'use client'
|
||||
|
||||
import { useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Key, Languages, Palette, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AbacusStylePanel } from '@/components/AbacusDisplayDropdown'
|
||||
import { AbacusDock } from '@/components/AbacusDock'
|
||||
import { LanguageSelector } from '@/components/LanguageSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
type TabId = 'general' | 'abacus' | 'mcp-keys'
|
||||
|
||||
interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'general', label: 'General', icon: <SettingsIcon size={16} /> },
|
||||
{ id: 'abacus', label: 'Abacus Style', icon: <Palette size={16} /> },
|
||||
{ id: 'mcp-keys', label: 'MCP Keys', icon: <Key size={16} /> },
|
||||
]
|
||||
|
||||
/**
|
||||
* User settings page for managing preferences across the app.
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const [activeTab, setActiveTab] = useState<TabId>('general')
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="settings-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxWidth: '700px', margin: '0 auto' })}>
|
||||
{/* Header */}
|
||||
<header data-element="settings-header" className={css({ marginBottom: '1.5rem' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<SettingsIcon
|
||||
size={28}
|
||||
className={css({ color: isDark ? 'purple.400' : 'purple.600' })}
|
||||
/>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Customize your Abaci One experience
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
data-element="tab-navigation"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
paddingBottom: '0',
|
||||
})}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-tab={tab.id}
|
||||
data-active={activeTab === tab.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color:
|
||||
activeTab === tab.id
|
||||
? isDark
|
||||
? 'purple.400'
|
||||
: 'purple.600'
|
||||
: isDark
|
||||
? 'gray.400'
|
||||
: 'gray.600',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid',
|
||||
borderColor:
|
||||
activeTab === tab.id ? (isDark ? 'purple.400' : 'purple.600') : 'transparent',
|
||||
marginBottom: '-1px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
color: isDark ? 'purple.300' : 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'general' && <GeneralTab isDark={isDark} />}
|
||||
{activeTab === 'abacus' && <AbacusTab isDark={isDark} />}
|
||||
{activeTab === 'mcp-keys' && <McpKeysTab isDark={isDark} />}
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* General settings tab - Theme, Language
|
||||
*/
|
||||
function GeneralTab({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<div data-section="general-tab">
|
||||
{/* Appearance Section */}
|
||||
<section className={css({ marginBottom: '1.5rem' })}>
|
||||
<SectionCard isDark={isDark}>
|
||||
<SectionHeader icon={<Palette size={18} />} title="Appearance" isDark={isDark} />
|
||||
<SettingRow
|
||||
label="Theme"
|
||||
description="Choose between light and dark mode"
|
||||
isDark={isDark}
|
||||
noBorder
|
||||
>
|
||||
<ThemeToggle />
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
{/* Language Section */}
|
||||
<section className={css({ marginBottom: '1.5rem' })}>
|
||||
<SectionCard isDark={isDark}>
|
||||
<SectionHeader icon={<Languages size={18} />} title="Language" isDark={isDark} />
|
||||
<SettingRow
|
||||
label="Display Language"
|
||||
description="Choose your preferred language"
|
||||
isDark={isDark}
|
||||
noBorder
|
||||
>
|
||||
<LanguageSelector variant="inline" />
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abacus Style tab - Full abacus customization
|
||||
*/
|
||||
function AbacusTab({ isDark }: { isDark: boolean }) {
|
||||
const { requestDock, undock, dock } = useMyAbacus()
|
||||
const { config } = useAbacusDisplay()
|
||||
|
||||
// Auto-dock when this tab mounts, auto-undock when it unmounts
|
||||
useEffect(() => {
|
||||
// Small delay to ensure dock is registered before requesting
|
||||
const timer = setTimeout(() => {
|
||||
requestDock()
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
undock()
|
||||
}
|
||||
}, [requestDock, undock])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-section="abacus-tab"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', lg: 'row' },
|
||||
gap: '1.5rem',
|
||||
alignItems: { base: 'stretch', lg: 'flex-start' },
|
||||
})}
|
||||
>
|
||||
{/* Settings Panel - Primary content */}
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<SectionCard isDark={isDark}>
|
||||
<div className={css({ padding: '1rem 0' })}>
|
||||
<AbacusStylePanel isDark={isDark} showHeader={true} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Live Preview - Sticky sidebar with AbacusDock */}
|
||||
<div
|
||||
className={css({
|
||||
width: { base: '100%', lg: '220px' },
|
||||
flexShrink: 0,
|
||||
position: 'sticky',
|
||||
top: '5rem',
|
||||
alignSelf: 'flex-start',
|
||||
order: { base: -1, lg: 0 },
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-element="abacus-preview"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
Preview
|
||||
</span>
|
||||
<AbacusDock
|
||||
id="settings-preview"
|
||||
columns={config.physicalAbacusColumns}
|
||||
interactive
|
||||
showNumbers={false}
|
||||
hideUndock
|
||||
className={css({
|
||||
width: '180px',
|
||||
height: '240px',
|
||||
})}
|
||||
/>
|
||||
{!dock && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Abacus will dock here
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MCP Keys Tab - API Key Management
|
||||
// ============================================
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
keyPreview: string
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
isRevoked: boolean
|
||||
}
|
||||
|
||||
interface NewKeyResponse {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: string
|
||||
message: string
|
||||
}
|
||||
|
||||
async function fetchApiKeys(): Promise<{ keys: ApiKey[] }> {
|
||||
const res = await api('settings/mcp-keys')
|
||||
if (!res.ok) throw new Error('Failed to fetch API keys')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function createApiKey(name: string): Promise<NewKeyResponse> {
|
||||
const res = await api('settings/mcp-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create API key')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function revokeApiKey(keyId: string): Promise<void> {
|
||||
const res = await api(`settings/mcp-keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to revoke API key')
|
||||
}
|
||||
|
||||
function McpKeysTab({ isDark }: { isDark: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Form state
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Fetch existing keys
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['mcp-api-keys'],
|
||||
queryFn: fetchApiKeys,
|
||||
})
|
||||
|
||||
// Create key mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createApiKey,
|
||||
onSuccess: (data) => {
|
||||
setNewlyCreatedKey(data.key)
|
||||
setNewKeyName('')
|
||||
queryClient.invalidateQueries({ queryKey: ['mcp-api-keys'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Revoke key mutation
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: revokeApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mcp-api-keys'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (newKeyName.trim()) {
|
||||
createMutation.mutate(newKeyName.trim())
|
||||
}
|
||||
}, [newKeyName, createMutation])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (newlyCreatedKey) {
|
||||
await navigator.clipboard.writeText(newlyCreatedKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [newlyCreatedKey])
|
||||
|
||||
const handleDismissNewKey = useCallback(() => {
|
||||
setNewlyCreatedKey(null)
|
||||
setCopied(false)
|
||||
}, [])
|
||||
|
||||
const activeKeys = data?.keys.filter((k) => !k.isRevoked) ?? []
|
||||
const revokedKeys = data?.keys.filter((k) => k.isRevoked) ?? []
|
||||
|
||||
return (
|
||||
<div data-section="mcp-keys-tab">
|
||||
{/* Description */}
|
||||
<p
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '1.5rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
Manage API keys for external tools like Claude Code to access student skill data.
|
||||
</p>
|
||||
|
||||
{/* Newly Created Key Banner */}
|
||||
{newlyCreatedKey && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'green.900/50' : 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'green.300' : 'green.800',
|
||||
})}
|
||||
>
|
||||
API Key Created
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissNewKey}
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: 1,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'green.200' : 'green.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Copy this key now - it won't be shown again!
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<code
|
||||
className={css({
|
||||
flex: 1,
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{newlyCreatedKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: copied ? 'green.500' : 'blue.500',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: { backgroundColor: copied ? 'green.600' : 'blue.600' },
|
||||
})}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Key Card */}
|
||||
<SectionCard isDark={isDark}>
|
||||
<h2
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
padding: '1rem 0',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
Generate New Key
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem 0',
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name (e.g., Claude Code)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
_placeholder: { color: isDark ? 'gray.500' : 'gray.400' },
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!newKeyName.trim() || createMutation.isPending}
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: newKeyName.trim() ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
|
||||
color: newKeyName.trim() ? 'white' : isDark ? 'gray.500' : 'gray.500',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
fontWeight: '600',
|
||||
cursor: newKeyName.trim() ? 'pointer' : 'not-allowed',
|
||||
_hover: newKeyName.trim() ? { backgroundColor: 'blue.600' } : {},
|
||||
})}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Active Keys List */}
|
||||
<div className={css({ marginTop: '1.5rem' })}>
|
||||
<SectionCard isDark={isDark}>
|
||||
<h2
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
padding: '1rem 0',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
Active Keys
|
||||
</h2>
|
||||
|
||||
<div className={css({ padding: '1rem 0' })}>
|
||||
{isLoading ? (
|
||||
<p className={css({ color: isDark ? 'gray.500' : 'gray.500' })}>Loading...</p>
|
||||
) : activeKeys.length === 0 ? (
|
||||
<p
|
||||
className={css({
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
No active API keys. Generate one above to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.75rem' })}>
|
||||
{activeKeys.map((key) => (
|
||||
<KeyRow
|
||||
key={key.id}
|
||||
apiKey={key}
|
||||
isDark={isDark}
|
||||
onRevoke={() => revokeMutation.mutate(key.id)}
|
||||
isRevoking={revokeMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Revoked Keys (collapsed by default) */}
|
||||
{revokedKeys.length > 0 && (
|
||||
<details
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.800/50' : 'gray.100',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
padding: '1rem 1.5rem',
|
||||
})}
|
||||
>
|
||||
<summary
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
cursor: 'pointer',
|
||||
_hover: { color: isDark ? 'gray.300' : 'gray.700' },
|
||||
})}
|
||||
>
|
||||
Revoked Keys ({revokedKeys.length})
|
||||
</summary>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
{revokedKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem 0',
|
||||
opacity: 0.6,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
textDecoration: 'line-through',
|
||||
})}
|
||||
>
|
||||
{key.name}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{key.keyPreview}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800/50' : 'blue.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'blue.100',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'blue.300' : 'blue.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Usage with Claude Code
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Add this to your <code>.mcp.json</code>:
|
||||
</p>
|
||||
<pre
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.900' : 'white',
|
||||
padding: '1rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
overflowX: 'auto',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
abaci: {
|
||||
type: 'http',
|
||||
url: `${typeof window !== 'undefined' ? window.location.origin : 'https://abaci.one'}/api/mcp`,
|
||||
headers: {
|
||||
Authorization: 'Bearer YOUR_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single API key row
|
||||
*/
|
||||
function KeyRow({
|
||||
apiKey,
|
||||
isDark,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
}: {
|
||||
apiKey: ApiKey
|
||||
isDark: boolean
|
||||
onRevoke: () => void
|
||||
isRevoking: boolean
|
||||
}) {
|
||||
const [confirmRevoke, setConfirmRevoke] = useState(false)
|
||||
|
||||
const handleRevokeClick = useCallback(() => {
|
||||
if (confirmRevoke) {
|
||||
onRevoke()
|
||||
setConfirmRevoke(false)
|
||||
} else {
|
||||
setConfirmRevoke(true)
|
||||
setTimeout(() => setConfirmRevoke(false), 3000)
|
||||
}
|
||||
}, [confirmRevoke, onRevoke])
|
||||
|
||||
const createdDate = new Date(apiKey.createdAt).toLocaleDateString()
|
||||
const lastUsed = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).toLocaleDateString() : 'Never'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'gray.700/50' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{apiKey.name}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontFamily: 'monospace' })}>{apiKey.keyPreview}</span>
|
||||
<span className={css({ marginLeft: '1rem' })}>Created: {createdDate}</span>
|
||||
<span className={css({ marginLeft: '1rem' })}>Last used: {lastUsed}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeClick}
|
||||
disabled={isRevoking}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: confirmRevoke ? 'red.500' : 'transparent',
|
||||
color: confirmRevoke ? 'white' : isDark ? 'red.400' : 'red.600',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: confirmRevoke ? 'red.500' : isDark ? 'red.400/50' : 'red.200',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
_hover: {
|
||||
backgroundColor: confirmRevoke ? 'red.600' : isDark ? 'red.900/30' : 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isRevoking ? 'Revoking...' : confirmRevoke ? 'Confirm Revoke' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Shared Components
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Card wrapper for a section
|
||||
*/
|
||||
function SectionCard({ children, isDark }: { children: React.ReactNode; isDark: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
padding: '0 1.5rem',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header with icon
|
||||
*/
|
||||
function SectionHeader({
|
||||
icon,
|
||||
title,
|
||||
isDark,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
isDark: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem 0',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'purple.400' : 'purple.600' })}>{icon}</span>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual setting row
|
||||
*/
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
isDark,
|
||||
noBorder = false,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
isDark: boolean
|
||||
noBorder?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
borderBottom: noBorder ? 'none' : '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: description ? '0.25rem' : 0,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{description && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { VisionTrainingNav } from '../components/VisionTrainingNav'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { VisionTrainingNavSlot } from '../components/VisionTrainingNavSlot'
|
||||
import { isValidModelType } from '../hooks/useModelType'
|
||||
|
||||
const NAV_HEIGHT = 56
|
||||
|
||||
interface VisionTrainingLayoutProps {
|
||||
children: ReactNode
|
||||
params: { model: string }
|
||||
@@ -19,12 +18,11 @@ interface VisionTrainingLayoutProps {
|
||||
* Layout wrapper for all vision training pages under /vision-training/[model]/.
|
||||
*
|
||||
* Provides:
|
||||
* - Fixed nav bar via VisionTrainingNav
|
||||
* - CSS custom property --nav-height for child pages to use
|
||||
* - Minimal nav bar with hamburger menu via PageWithNav
|
||||
* - Contextual nav content (model selector + tabs) via VisionTrainingNavSlot
|
||||
* - Model param validation (redirects to 404 if invalid)
|
||||
*
|
||||
* The --nav-height variable allows child pages with absolute/fixed positioning
|
||||
* to correctly offset their content below the nav bar.
|
||||
* Uses the same PageWithNav + navSlot pattern as arcade pages for consistent UX.
|
||||
*/
|
||||
export default function VisionTrainingLayout({ children, params }: VisionTrainingLayoutProps) {
|
||||
// Validate model param - show 404 for invalid models
|
||||
@@ -33,28 +31,17 @@ export default function VisionTrainingLayout({ children, params }: VisionTrainin
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="vision-training-layout"
|
||||
style={{ '--nav-height': `${NAV_HEIGHT}px` } as React.CSSProperties}
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gray.900',
|
||||
color: 'gray.100',
|
||||
})}
|
||||
>
|
||||
{/* Fixed nav - always at top */}
|
||||
<VisionTrainingNav />
|
||||
|
||||
{/* Content area - pushed below nav via padding */}
|
||||
<main
|
||||
data-element="vision-content"
|
||||
<PageWithNav navSlot={<VisionTrainingNavSlot />}>
|
||||
<div
|
||||
data-component="vision-training-layout"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gray.900',
|
||||
color: 'gray.100',
|
||||
})}
|
||||
style={{ paddingTop: 'var(--nav-height)' }}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<main data-element="vision-content">{children}</main>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { getValidModelTypes, useModelType } from '../hooks/useModelType'
|
||||
import { getModelEntry, TABS, type TabId } from '../registry'
|
||||
import type { ModelType } from '../train/components/wizard/types'
|
||||
|
||||
/**
|
||||
* Vision Training Nav Slot
|
||||
*
|
||||
* Content for the minimal nav's center slot when on vision training pages.
|
||||
* Contains:
|
||||
* - Model selector dropdown
|
||||
* - Tab links (Data, Train, Test, Sessions)
|
||||
*
|
||||
* Used with PageWithNav's navSlot prop.
|
||||
*/
|
||||
export function VisionTrainingNavSlot() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const modelType = useModelType()
|
||||
const allModelTypes = getValidModelTypes()
|
||||
|
||||
// Determine active tab from pathname
|
||||
const getActiveTab = (): TabId => {
|
||||
if (pathname.includes('/train')) return 'train'
|
||||
if (pathname.includes('/test')) return 'test'
|
||||
if (pathname.includes('/sessions')) return 'sessions'
|
||||
return 'data'
|
||||
}
|
||||
|
||||
const activeTab = getActiveTab()
|
||||
|
||||
// Handle model change - preserve current tab
|
||||
const handleModelChange = useCallback(
|
||||
(newModel: ModelType) => {
|
||||
// Get current path suffix (e.g., /train, /test, /sessions)
|
||||
const pathParts = pathname.split('/')
|
||||
const modelIndex = pathParts.indexOf(modelType)
|
||||
const suffix = pathParts.slice(modelIndex + 1).join('/')
|
||||
|
||||
// Navigate to new model path with same suffix
|
||||
const newPath = suffix
|
||||
? `/vision-training/${newModel}/${suffix}`
|
||||
: `/vision-training/${newModel}`
|
||||
|
||||
router.push(newPath)
|
||||
},
|
||||
[pathname, modelType, router]
|
||||
)
|
||||
|
||||
// Get tab path
|
||||
const getTabPath = (tabId: TabId): string => {
|
||||
const basePath = `/vision-training/${modelType}`
|
||||
if (tabId === 'data') return basePath
|
||||
return `${basePath}/${tabId}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="vision-training-nav-slot"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
})}
|
||||
>
|
||||
{/* Title - hidden on small screens */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.500',
|
||||
display: { base: 'none', md: 'block' },
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
Vision Training
|
||||
</span>
|
||||
|
||||
{/* Model selector - dark accent dropdown */}
|
||||
<select
|
||||
value={modelType}
|
||||
onChange={(e) => handleModelChange(e.target.value as ModelType)}
|
||||
data-action="select-model"
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
bg: 'gray.800',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'lg',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'sm',
|
||||
_hover: { bg: 'gray.700' },
|
||||
_focus: { outline: 'none', ring: '2px', ringColor: 'blue.500', ringOffset: '2px' },
|
||||
})}
|
||||
>
|
||||
{allModelTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getModelEntry(type).label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Separator */}
|
||||
<span className={css({ color: 'gray.300', display: { base: 'none', sm: 'block' } })}>|</span>
|
||||
|
||||
{/* Tab links */}
|
||||
<div
|
||||
data-element="tab-links"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
})}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={getTabPath(tab.id)}
|
||||
data-action={`tab-${tab.id}`}
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
color: isActive ? 'blue.600' : 'gray.600',
|
||||
textDecoration: 'none',
|
||||
fontWeight: isActive ? 'semibold' : 'medium',
|
||||
fontSize: 'sm',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: isActive ? 'blue.600' : 'transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
color: isActive ? 'blue.600' : 'gray.900',
|
||||
bg: isActive ? 'transparent' : 'gray.100',
|
||||
borderRadius: isActive ? '0' : 'md',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({ display: { base: 'none', sm: 'block' } })}>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,25 +6,286 @@ import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import { type BeadShape, type ColorScheme, useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import { useState } from 'react'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { hstack, stack } from '../../styled-system/patterns'
|
||||
import { useAbacusSettings, useUpdateAbacusSettings } from '../hooks/useAbacusSettings'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useAbacusSettings, useUpdateAbacusSettings } from '../hooks/useAbacusSettings'
|
||||
|
||||
interface AbacusDisplayDropdownProps {
|
||||
isFullscreen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone panel for abacus style settings.
|
||||
* Can be used directly on settings pages or inside dropdowns.
|
||||
*/
|
||||
export function AbacusStylePanel({
|
||||
isFullscreen = false,
|
||||
isDark = false,
|
||||
showHeader = true,
|
||||
}: {
|
||||
isFullscreen?: boolean
|
||||
isDark?: boolean
|
||||
showHeader?: boolean
|
||||
}) {
|
||||
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
|
||||
const { data: abacusSettings } = useAbacusSettings()
|
||||
const { mutate: updateAbacusSettings } = useUpdateAbacusSettings()
|
||||
|
||||
return (
|
||||
<div className={stack({ gap: '6' })}>
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<div className={stack({ gap: '1' })}>
|
||||
<div
|
||||
className={hstack({
|
||||
justify: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
🎨 Abacus Style
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetToDefaults}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.500',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Configure display across the entire app
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Scheme */}
|
||||
<FormField label="Color Scheme" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<RadioGroupField
|
||||
value={config.colorScheme}
|
||||
onValueChange={(value) => updateConfig({ colorScheme: value as ColorScheme })}
|
||||
options={[
|
||||
{ value: 'monochrome', label: 'Monochrome' },
|
||||
{ value: 'place-value', label: 'Place Value' },
|
||||
{ value: 'heaven-earth', label: 'Heaven-Earth' },
|
||||
{ value: 'alternating', label: 'Alternating' },
|
||||
]}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Bead Shape */}
|
||||
<FormField label="Bead Shape" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<RadioGroupField
|
||||
value={config.beadShape}
|
||||
onValueChange={(value) => updateConfig({ beadShape: value as BeadShape })}
|
||||
options={[
|
||||
{ value: 'diamond', label: '💎 Diamond' },
|
||||
{ value: 'circle', label: '⭕ Circle' },
|
||||
{ value: 'square', label: '⬜ Square' },
|
||||
]}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Toggle Options */}
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<FormField label="Hide Inactive Beads" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.hideInactiveBeads}
|
||||
onCheckedChange={(checked) => updateConfig({ hideInactiveBeads: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Colored Numerals" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.coloredNumerals}
|
||||
onCheckedChange={(checked) => updateConfig({ coloredNumerals: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sound Effects" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.soundEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{config.soundEnabled && (
|
||||
<FormField
|
||||
label={`Volume: ${Math.round(config.soundVolume * 100)}%`}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.soundVolume}
|
||||
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '2',
|
||||
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : isDark ? 'gray.700' : 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Native Abacus Numbers" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={abacusSettings?.nativeAbacusNumbers ?? false}
|
||||
onCheckedChange={(checked) => updateAbacusSettings({ nativeAbacusNumbers: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={`Physical Abacus Columns: ${config.physicalAbacusColumns}`}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="21"
|
||||
step="1"
|
||||
value={config.physicalAbacusColumns}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
physicalAbacusColumns: parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
className={css({
|
||||
flex: 1,
|
||||
h: '2',
|
||||
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : isDark ? 'gray.700' : 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
|
||||
minW: '6',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
{config.physicalAbacusColumns}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isFullscreen ? 'gray.400' : isDark ? 'gray.500' : 'gray.500',
|
||||
mt: '1',
|
||||
})}
|
||||
>
|
||||
For camera vision detection
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AbacusDisplayDropdown({
|
||||
isFullscreen = false,
|
||||
onOpenChange: onOpenChangeProp,
|
||||
}: AbacusDisplayDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
|
||||
const { data: abacusSettings } = useAbacusSettings()
|
||||
const { mutate: updateAbacusSettings } = useUpdateAbacusSettings()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
@@ -38,6 +299,7 @@ export function AbacusDisplayDropdown({
|
||||
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -120,255 +382,7 @@ export function AbacusDisplayDropdown({
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
{/* Header */}
|
||||
<div className={stack({ gap: '1' })}>
|
||||
<div
|
||||
className={hstack({
|
||||
justify: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
🎨 Abacus Style
|
||||
</h3>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.500',
|
||||
_hover: {
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Configure display across the entire app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Scheme */}
|
||||
<FormField label="Color Scheme" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<RadioGroupField
|
||||
value={config.colorScheme}
|
||||
onValueChange={(value) => updateConfig({ colorScheme: value as ColorScheme })}
|
||||
options={[
|
||||
{ value: 'monochrome', label: 'Monochrome' },
|
||||
{ value: 'place-value', label: 'Place Value' },
|
||||
{ value: 'heaven-earth', label: 'Heaven-Earth' },
|
||||
{ value: 'alternating', label: 'Alternating' },
|
||||
]}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Bead Shape */}
|
||||
<FormField label="Bead Shape" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<RadioGroupField
|
||||
value={config.beadShape}
|
||||
onValueChange={(value) => updateConfig({ beadShape: value as BeadShape })}
|
||||
options={[
|
||||
{ value: 'diamond', label: '💎 Diamond' },
|
||||
{ value: 'circle', label: '⭕ Circle' },
|
||||
{ value: 'square', label: '⬜ Square' },
|
||||
]}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Toggle Options */}
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<FormField label="Hide Inactive Beads" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.hideInactiveBeads}
|
||||
onCheckedChange={(checked) => updateConfig({ hideInactiveBeads: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Colored Numerals" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.coloredNumerals}
|
||||
onCheckedChange={(checked) => updateConfig({ coloredNumerals: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sound Effects" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={config.soundEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{config.soundEnabled && (
|
||||
<FormField
|
||||
label={`Volume: ${Math.round(config.soundVolume * 100)}%`}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.soundVolume}
|
||||
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '2',
|
||||
bg: isFullscreen
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Native Abacus Numbers" isFullscreen={isFullscreen} isDark={isDark}>
|
||||
<SwitchField
|
||||
checked={abacusSettings?.nativeAbacusNumbers ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAbacusSettings({ nativeAbacusNumbers: checked })
|
||||
}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={`Physical Abacus Columns: ${config.physicalAbacusColumns}`}
|
||||
isFullscreen={isFullscreen}
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="21"
|
||||
step="1"
|
||||
value={config.physicalAbacusColumns}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
physicalAbacusColumns: parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
className={css({
|
||||
flex: 1,
|
||||
h: '2',
|
||||
bg: isFullscreen
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
|
||||
minW: '6',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
{config.physicalAbacusColumns}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isFullscreen ? 'gray.400' : isDark ? 'gray.500' : 'gray.500',
|
||||
mt: '1',
|
||||
})}
|
||||
>
|
||||
For camera vision detection
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<AbacusStylePanel isFullscreen={isFullscreen} isDark={isDark} showHeader={true} />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from 'react'
|
||||
import { useMyAbacus, type DockConfig } from '@/contexts/MyAbacusContext'
|
||||
import { type CSSProperties, type HTMLAttributes, useEffect, useRef } from 'react'
|
||||
import { type DockConfig, useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
|
||||
export interface AbacusDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
/** Optional identifier for debugging */
|
||||
@@ -22,6 +22,8 @@ export interface AbacusDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'c
|
||||
defaultValue?: number
|
||||
/** Callback when value changes (for controlled mode) */
|
||||
onValueChange?: (newValue: number) => void
|
||||
/** Hide the undock button (default: false) */
|
||||
hideUndock?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,13 +57,14 @@ export function AbacusDock({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
hideUndock = false,
|
||||
style,
|
||||
...divProps
|
||||
}: AbacusDockProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { registerDock, unregisterDock, updateDockVisibility } = useMyAbacus()
|
||||
const { registerDock, unregisterDock, updateDockVisibility, updateDockConfig } = useMyAbacus()
|
||||
|
||||
// Register the dock
|
||||
// Register the dock on mount, unregister on unmount
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
@@ -77,6 +80,7 @@ export function AbacusDock({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
hideUndock,
|
||||
isVisible: false, // Will be updated by IntersectionObserver
|
||||
}
|
||||
|
||||
@@ -85,6 +89,27 @@ export function AbacusDock({
|
||||
return () => {
|
||||
unregisterDock(element)
|
||||
}
|
||||
// Only register/unregister on mount/unmount - config updates happen separately
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [registerDock, unregisterDock])
|
||||
|
||||
// Update dock config when props change (without re-registering)
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
updateDockConfig(element, {
|
||||
id,
|
||||
columns,
|
||||
interactive,
|
||||
showNumbers,
|
||||
animated,
|
||||
scaleFactor,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
hideUndock,
|
||||
})
|
||||
}, [
|
||||
id,
|
||||
columns,
|
||||
@@ -95,8 +120,8 @@ export function AbacusDock({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
registerDock,
|
||||
unregisterDock,
|
||||
hideUndock,
|
||||
updateDockConfig,
|
||||
])
|
||||
|
||||
// Track visibility with IntersectionObserver
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import React, { useContext, useMemo, useState } from 'react'
|
||||
@@ -17,16 +16,9 @@ import { useVisualDebug } from '../contexts/VisualDebugContext'
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { LanguageSelector } from './LanguageSelector'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
// Lazy load AbacusDisplayDropdown - it imports @soroban/abacus-react and multiple Radix components
|
||||
// Only loaded when user actually opens the settings menu
|
||||
const AbacusDisplayDropdown = dynamic(
|
||||
() => import('./AbacusDisplayDropdown').then((m) => m.AbacusDisplayDropdown),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type HomeHeroContextValue = {
|
||||
subtitle: Subtitle
|
||||
isHeroVisible: boolean
|
||||
@@ -64,7 +56,6 @@ function MenuContent({
|
||||
toggleFullscreen,
|
||||
router,
|
||||
onNavigate,
|
||||
handleNestedDropdownChange,
|
||||
isMobile,
|
||||
resolvedTheme,
|
||||
}: {
|
||||
@@ -74,7 +65,6 @@ function MenuContent({
|
||||
toggleFullscreen: () => void
|
||||
router: any
|
||||
onNavigate?: () => void
|
||||
handleNestedDropdownChange?: (isOpen: boolean) => void
|
||||
isMobile?: boolean
|
||||
resolvedTheme?: 'light' | 'dark'
|
||||
}) {
|
||||
@@ -297,32 +287,73 @@ function MenuContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2: Style + Language + Theme */}
|
||||
{/* Column 2: Settings + Developer */}
|
||||
<div>
|
||||
{/* Style Section */}
|
||||
<div style={sectionHeaderStyle}>Abacus Style</div>
|
||||
{/* Settings Link */}
|
||||
<div style={sectionHeaderStyle}>Settings</div>
|
||||
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<AbacusDisplayDropdown
|
||||
isFullscreen={isFullscreen}
|
||||
onOpenChange={handleNestedDropdownChange}
|
||||
/>
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={
|
||||
isMobile
|
||||
? (e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/settings')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(139, 92, 246, 0.2)'
|
||||
: 'rgba(139, 92, 246, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(196, 181, 253, 1)'
|
||||
: 'rgba(109, 40, 217, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>⚙️</span>
|
||||
<span>Preferences</span>
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggle - Quick Access */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isMobile ? '10px' : '10px',
|
||||
padding: isMobile ? '8px 12px' : '10px 14px',
|
||||
}}
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div style={separatorStyle} />
|
||||
|
||||
{/* Language Section */}
|
||||
<div style={sectionHeaderStyle}>Language</div>
|
||||
|
||||
<LanguageSelector variant="dropdown-item" isFullscreen={isFullscreen} />
|
||||
|
||||
<div style={separatorStyle} />
|
||||
|
||||
{/* Theme Section */}
|
||||
<div style={sectionHeaderStyle}>Theme</div>
|
||||
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<ThemeToggle />
|
||||
{/* Abacus Style - Quick Access */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isMobile ? '10px' : '10px',
|
||||
padding: isMobile ? '8px 12px' : '10px 14px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: isMobile ? '18px' : '16px' }}>🧮</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: isMobile ? '14px' : '14px',
|
||||
fontWeight: 500,
|
||||
color: isDark ? 'rgba(209, 213, 219, 1)' : 'rgba(55, 65, 81, 1)',
|
||||
}}
|
||||
>
|
||||
Abacus Style
|
||||
</span>
|
||||
<AbacusDisplayDropdown />
|
||||
</div>
|
||||
|
||||
{/* Developer Section - shown in dev or when ?debug=1 is used */}
|
||||
@@ -330,6 +361,58 @@ function MenuContent({
|
||||
<>
|
||||
<div style={separatorStyle} />
|
||||
<div style={sectionHeaderStyle}>Developer</div>
|
||||
<Link
|
||||
href="/debug"
|
||||
data-action="debug-hub-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/debug')
|
||||
}}
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>🛠️</span>
|
||||
<span>Debug Hub</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/vision-training"
|
||||
data-action="vision-training-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('/vision-training')
|
||||
}}
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>👁️</span>
|
||||
<span>Vision Training</span>
|
||||
</Link>
|
||||
<div
|
||||
data-setting="visual-debug"
|
||||
onClick={() => {
|
||||
@@ -424,37 +507,120 @@ function MenuContent({
|
||||
|
||||
<DropdownMenu.Separator style={separatorStyle} />
|
||||
|
||||
{/* Style Section */}
|
||||
<div style={sectionHeaderStyle}>Abacus Style</div>
|
||||
{/* Settings Section */}
|
||||
<div style={sectionHeaderStyle}>Settings</div>
|
||||
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<AbacusDisplayDropdown
|
||||
isFullscreen={isFullscreen}
|
||||
onOpenChange={handleNestedDropdownChange}
|
||||
/>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(139, 92, 246, 0.2)'
|
||||
: 'rgba(139, 92, 246, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(196, 181, 253, 1)'
|
||||
: 'rgba(109, 40, 217, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>⚙️</span>
|
||||
<span>Preferences</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{/* Theme Toggle - Quick Access */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
}}
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator style={separatorStyle} />
|
||||
|
||||
{/* Language Section */}
|
||||
<div style={sectionHeaderStyle}>Language</div>
|
||||
|
||||
<LanguageSelector variant="dropdown-item" isFullscreen={isFullscreen} />
|
||||
|
||||
<DropdownMenu.Separator style={separatorStyle} />
|
||||
|
||||
{/* Theme Section */}
|
||||
<div style={sectionHeaderStyle}>Theme</div>
|
||||
|
||||
<DropdownMenu.Item onSelect={(e) => e.preventDefault()} style={{ padding: '0 6px' }}>
|
||||
<ThemeToggle />
|
||||
</DropdownMenu.Item>
|
||||
{/* Abacus Style - Quick Access */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🧮</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: isDark ? 'rgba(209, 213, 219, 1)' : 'rgba(55, 65, 81, 1)',
|
||||
}}
|
||||
>
|
||||
Abacus Style
|
||||
</span>
|
||||
<AbacusDisplayDropdown />
|
||||
</div>
|
||||
|
||||
{/* Developer Section - shown in dev or when ?debug=1 is used */}
|
||||
{isDebugAllowed && (
|
||||
<>
|
||||
<DropdownMenu.Separator style={separatorStyle} />
|
||||
<div style={sectionHeaderStyle}>Developer</div>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href="/debug"
|
||||
data-action="debug-hub-link"
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🛠️</span>
|
||||
<span>Debug Hub</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href="/vision-training"
|
||||
data-action="vision-training-link"
|
||||
style={linkStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>👁️</span>
|
||||
<span>Vision Training</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-setting="visual-debug"
|
||||
onSelect={toggleVisualDebug}
|
||||
@@ -499,10 +665,8 @@ function HamburgerMenu({
|
||||
router: any
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [nestedDropdownOpen, setNestedDropdownOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { open: openDeploymentInfo } = useDeploymentInfo()
|
||||
|
||||
// Detect mobile viewport - check the smaller dimension to catch landscape orientation
|
||||
React.useEffect(() => {
|
||||
@@ -516,17 +680,10 @@ function HamburgerMenu({
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// Open on click OR if nested dropdown is open
|
||||
const isOpen = open || nestedDropdownOpen
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
const handleNestedDropdownChange = (isNestedOpen: boolean) => {
|
||||
setNestedDropdownOpen(isNestedOpen)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -637,7 +794,6 @@ function HamburgerMenu({
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
router={router}
|
||||
onNavigate={handleClose}
|
||||
handleNestedDropdownChange={handleNestedDropdownChange}
|
||||
isMobile={true}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
@@ -666,7 +822,7 @@ function HamburgerMenu({
|
||||
|
||||
// Desktop dropdown menu
|
||||
return (
|
||||
<DropdownMenu.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -747,7 +903,6 @@ function HamburgerMenu({
|
||||
pathname={pathname}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
router={router}
|
||||
handleNestedDropdownChange={handleNestedDropdownChange}
|
||||
isMobile={false}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
@@ -884,6 +1039,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const isVisionTrainingPage = pathname?.startsWith('/vision-training')
|
||||
const isHomePage = pathname === '/'
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
const { open: openDeploymentInfo } = useDeploymentInfo()
|
||||
@@ -900,8 +1056,9 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const showBranding = !isHomePage || !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
// Only arcade pages (not /games) should use minimal nav
|
||||
const actualVariant = variant === 'full' && isArcadePage ? 'minimal' : variant
|
||||
// Arcade and vision training pages use minimal nav with hamburger + centered content
|
||||
const actualVariant =
|
||||
variant === 'full' && (isArcadePage || isVisionTrainingPage) ? 'minimal' : variant
|
||||
|
||||
// Mini nav for games/arcade (both fullscreen and non-fullscreen)
|
||||
if (actualVariant === 'minimal') {
|
||||
|
||||
@@ -608,36 +608,38 @@ export function MyAbacus() {
|
||||
{/* Left side: Vision indicator */}
|
||||
<VisionIndicator size="small" position="inline" />
|
||||
|
||||
{/* Right side: Undock button */}
|
||||
<button
|
||||
data-action="undock-abacus"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleUndockClick()
|
||||
}}
|
||||
title="Undock abacus"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
w: '24px',
|
||||
h: '24px',
|
||||
bg: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 'md',
|
||||
color: 'gray.400',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
bg: 'blue.600',
|
||||
borderColor: 'blue.600',
|
||||
color: 'white',
|
||||
},
|
||||
})}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
{/* Right side: Undock button (hidden when dock.hideUndock is true) */}
|
||||
{!dock.hideUndock && (
|
||||
<button
|
||||
data-action="undock-abacus"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleUndockClick()
|
||||
}}
|
||||
title="Undock abacus"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
w: '24px',
|
||||
h: '24px',
|
||||
bg: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 'md',
|
||||
color: 'gray.400',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
bg: 'blue.600',
|
||||
borderColor: 'blue.600',
|
||||
color: 'white',
|
||||
},
|
||||
})}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -40,6 +40,8 @@ interface PageWithNavProps {
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
children: React.ReactNode
|
||||
// Custom nav slot content (for non-game pages like vision-training)
|
||||
navSlot?: React.ReactNode
|
||||
// Game state for turn indicator
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
@@ -93,6 +95,7 @@ export function PageWithNav({
|
||||
customModeLabel,
|
||||
customModeEmoji,
|
||||
customModeColor,
|
||||
navSlot,
|
||||
}: PageWithNavProps) {
|
||||
// In preview mode, render just the children without navigation
|
||||
const previewMode = useContext(PreviewModeContext)
|
||||
@@ -134,9 +137,10 @@ export function PageWithNav({
|
||||
|
||||
// For non-game pages, render just the AppNavBar without game features
|
||||
// This avoids loading GameModeContext, useRoomData, etc.
|
||||
// Pass navSlot if provided (e.g., VisionTrainingNavSlot for vision-training pages)
|
||||
return (
|
||||
<>
|
||||
<AppNavBar navSlot={null} />
|
||||
<AppNavBar navSlot={navSlot ?? null} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { CalibrationGrid } from '@/types/vision'
|
||||
import type { ColumnImageData } from '@/lib/vision/trainingData'
|
||||
import { imageDataToBase64Png } from '@/lib/vision/trainingData'
|
||||
import type { CalibrationGrid } from '@/types/vision'
|
||||
|
||||
/**
|
||||
* Camera source type for vision
|
||||
@@ -119,6 +119,8 @@ export interface DockConfig {
|
||||
defaultValue?: number
|
||||
/** Callback when value changes (for controlled mode) */
|
||||
onValueChange?: (newValue: number) => void
|
||||
/** Hide the undock button (default: false) */
|
||||
hideUndock?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +197,8 @@ interface MyAbacusContextValue {
|
||||
unregisterDock: (element: HTMLElement) => void
|
||||
/** Update dock visibility status */
|
||||
updateDockVisibility: (element: HTMLElement, isVisible: boolean) => void
|
||||
/** Update dock configuration without re-registering */
|
||||
updateDockConfig: (element: HTMLElement, config: Partial<Omit<DockConfig, 'element'>>) => void
|
||||
/** Whether the abacus is currently docked (user chose to dock it) */
|
||||
isDockedByUser: boolean
|
||||
/** Dock the abacus into the current dock */
|
||||
@@ -279,6 +283,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pendingDockRequest, setPendingDockRequest] = useState(false)
|
||||
const [abacusValue, setAbacusValue] = useState(0)
|
||||
|
||||
// Ref to track dock existence for stable requestDock callback
|
||||
const dockRef = useRef<DockConfig | null>(null)
|
||||
useEffect(() => {
|
||||
dockRef.current = dock
|
||||
}, [dock])
|
||||
|
||||
// Vision state
|
||||
const [visionConfig, setVisionConfig] = useState<VisionConfig>(DEFAULT_VISION_CONFIG)
|
||||
const [isVisionSetupOpen, setIsVisionSetupOpen] = useState(false)
|
||||
@@ -319,6 +329,18 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateDockConfig = useCallback(
|
||||
(element: HTMLElement, config: Partial<Omit<DockConfig, 'element'>>) => {
|
||||
setDock((current) => {
|
||||
if (current?.element === element) {
|
||||
return { ...current, ...config }
|
||||
}
|
||||
return current
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dockInto = useCallback(() => {
|
||||
if (dock) {
|
||||
setIsDockedByUser(true)
|
||||
@@ -351,11 +373,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
// Request to dock with animation (triggers MyAbacus to animate into dock)
|
||||
// Uses ref to avoid recreating callback when dock config changes
|
||||
const requestDock = useCallback(() => {
|
||||
if (dock) {
|
||||
if (dockRef.current) {
|
||||
setPendingDockRequest(true)
|
||||
}
|
||||
}, [dock])
|
||||
}, [])
|
||||
|
||||
// Clear the pending dock request after MyAbacus handles it
|
||||
const clearDockRequest = useCallback(() => {
|
||||
@@ -538,6 +561,7 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||
registerDock,
|
||||
unregisterDock,
|
||||
updateDockVisibility,
|
||||
updateDockConfig,
|
||||
isDockedByUser,
|
||||
dockInto,
|
||||
undock,
|
||||
|
||||
Reference in New Issue
Block a user