From 9f483b142e747a19d8aabdd11714545bd9e7de37 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 15 Jan 2026 15:23:37 -0600 Subject: [PATCH] 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 --- apps/web/src/app/settings/page.tsx | 922 ++++++++++++++++++ .../app/vision-training/[model]/layout.tsx | 39 +- .../components/VisionTrainingNavSlot.tsx | 158 +++ .../src/components/AbacusDisplayDropdown.tsx | 522 +++++----- apps/web/src/components/AbacusDock.tsx | 37 +- apps/web/src/components/AppNavBar.tsx | 295 ++++-- apps/web/src/components/MyAbacus.tsx | 62 +- apps/web/src/components/PageWithNav.tsx | 6 +- apps/web/src/contexts/MyAbacusContext.tsx | 30 +- 9 files changed, 1682 insertions(+), 389 deletions(-) create mode 100644 apps/web/src/app/settings/page.tsx create mode 100644 apps/web/src/app/vision-training/components/VisionTrainingNavSlot.tsx diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx new file mode 100644 index 00000000..1788bb62 --- /dev/null +++ b/apps/web/src/app/settings/page.tsx @@ -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: }, + { id: 'abacus', label: 'Abacus Style', icon: }, + { id: 'mcp-keys', label: 'MCP Keys', icon: }, +] + +/** + * User settings page for managing preferences across the app. + */ +export default function SettingsPage() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [activeTab, setActiveTab] = useState('general') + + return ( + +
+
+ {/* Header */} +
+
+ +

+ Settings +

+
+

+ Customize your Abaci One experience +

+
+ + {/* Tab Navigation */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab Content */} + {activeTab === 'general' && } + {activeTab === 'abacus' && } + {activeTab === 'mcp-keys' && } +
+
+
+ ) +} + +/** + * General settings tab - Theme, Language + */ +function GeneralTab({ isDark }: { isDark: boolean }) { + return ( +
+ {/* Appearance Section */} +
+ + } title="Appearance" isDark={isDark} /> + + + + +
+ + {/* Language Section */} +
+ + } title="Language" isDark={isDark} /> + + + + +
+
+ ) +} + +/** + * 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 ( +
+ {/* Settings Panel - Primary content */} +
+ +
+ +
+
+
+ + {/* Live Preview - Sticky sidebar with AbacusDock */} +
+
+ + Preview + + + {!dock && ( +

+ Abacus will dock here +

+ )} +
+
+
+ ) +} + +// ============================================ +// 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 { + 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 { + 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(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 ( +
+ {/* Description */} +

+ Manage API keys for external tools like Claude Code to access student skill data. +

+ + {/* Newly Created Key Banner */} + {newlyCreatedKey && ( +
+
+

+ API Key Created +

+ +
+

+ Copy this key now - it won't be shown again! +

+
+ + {newlyCreatedKey} + + +
+
+ )} + + {/* Create New Key Card */} + +

+ Generate New Key +

+
+ 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' }, + })} + /> + +
+
+ + {/* Active Keys List */} +
+ +

+ Active Keys +

+ +
+ {isLoading ? ( +

Loading...

+ ) : activeKeys.length === 0 ? ( +

+ No active API keys. Generate one above to get started. +

+ ) : ( +
+ {activeKeys.map((key) => ( + revokeMutation.mutate(key.id)} + isRevoking={revokeMutation.isPending} + /> + ))} +
+ )} +
+
+
+ + {/* Revoked Keys (collapsed by default) */} + {revokedKeys.length > 0 && ( +
+ + Revoked Keys ({revokedKeys.length}) + +
+ {revokedKeys.map((key) => ( +
+
+ + {key.name} + + + {key.keyPreview} + +
+
+ ))} +
+
+ )} + + {/* Usage Instructions */} +
+

+ Usage with Claude Code +

+

+ Add this to your .mcp.json: +

+
+          {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
+          )}
+        
+
+
+ ) +} + +/** + * 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 ( +
+
+
+ {apiKey.name} +
+
+ {apiKey.keyPreview} + Created: {createdDate} + Last used: {lastUsed} +
+
+ +
+ ) +} + +// ============================================ +// Shared Components +// ============================================ + +/** + * Card wrapper for a section + */ +function SectionCard({ children, isDark }: { children: React.ReactNode; isDark: boolean }) { + return ( +
+ {children} +
+ ) +} + +/** + * Section header with icon + */ +function SectionHeader({ + icon, + title, + isDark, +}: { + icon: React.ReactNode + title: string + isDark: boolean +}) { + return ( +
+ {icon} +

+ {title} +

+
+ ) +} + +/** + * Individual setting row + */ +function SettingRow({ + label, + description, + children, + isDark, + noBorder = false, +}: { + label: string + description?: string + children: React.ReactNode + isDark: boolean + noBorder?: boolean +}) { + return ( +
+
+
+ {label} +
+ {description && ( +
+ {description} +
+ )} +
+
{children}
+
+ ) +} diff --git a/apps/web/src/app/vision-training/[model]/layout.tsx b/apps/web/src/app/vision-training/[model]/layout.tsx index 5ff20ffe..d0a7032a 100644 --- a/apps/web/src/app/vision-training/[model]/layout.tsx +++ b/apps/web/src/app/vision-training/[model]/layout.tsx @@ -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 ( -
- {/* Fixed nav - always at top */} - - - {/* Content area - pushed below nav via padding */} -
}> +
- {children} -
-
+
{children}
+ + ) } diff --git a/apps/web/src/app/vision-training/components/VisionTrainingNavSlot.tsx b/apps/web/src/app/vision-training/components/VisionTrainingNavSlot.tsx new file mode 100644 index 00000000..5d67e9eb --- /dev/null +++ b/apps/web/src/app/vision-training/components/VisionTrainingNavSlot.tsx @@ -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 ( +
+ {/* Title - hidden on small screens */} + + Vision Training + + + {/* Model selector - dark accent dropdown */} + + + {/* Separator */} + | + + {/* Tab links */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.id + return ( + + {tab.icon} + {tab.label} + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/AbacusDisplayDropdown.tsx b/apps/web/src/components/AbacusDisplayDropdown.tsx index 3c13a408..1402acf8 100644 --- a/apps/web/src/components/AbacusDisplayDropdown.tsx +++ b/apps/web/src/components/AbacusDisplayDropdown.tsx @@ -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 ( +
+ {/* Header */} + {showHeader && ( +
+
+

+ 🎨 Abacus Style +

+ +
+

+ Configure display across the entire app +

+
+ )} + + {/* Color Scheme */} + + 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} + /> + + + {/* Bead Shape */} + + updateConfig({ beadShape: value as BeadShape })} + options={[ + { value: 'diamond', label: '💎 Diamond' }, + { value: 'circle', label: '⭕ Circle' }, + { value: 'square', label: '⬜ Square' }, + ]} + isFullscreen={isFullscreen} + isDark={isDark} + /> + + + {/* Toggle Options */} +
+ + updateConfig({ hideInactiveBeads: checked })} + isFullscreen={isFullscreen} + isDark={isDark} + /> + + + + updateConfig({ coloredNumerals: checked })} + isFullscreen={isFullscreen} + isDark={isDark} + /> + + + + updateConfig({ soundEnabled: checked })} + isFullscreen={isFullscreen} + isDark={isDark} + /> + + + {config.soundEnabled && ( + + 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', + }, + })} + /> + + )} + + + updateAbacusSettings({ nativeAbacusNumbers: checked })} + isFullscreen={isFullscreen} + isDark={isDark} + /> + + + +
+ + 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', + }, + })} + /> + + {config.physicalAbacusColumns} + +
+

+ For camera vision detection +

+
+
+
+ ) +} + 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({ - -

- Configure display across the entire app -

- - - {/* Color Scheme */} - - 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} - /> - - - {/* Bead Shape */} - - updateConfig({ beadShape: value as BeadShape })} - options={[ - { value: 'diamond', label: '💎 Diamond' }, - { value: 'circle', label: '⭕ Circle' }, - { value: 'square', label: '⬜ Square' }, - ]} - isFullscreen={isFullscreen} - isDark={isDark} - /> - - - {/* Toggle Options */} -
- - updateConfig({ hideInactiveBeads: checked })} - isFullscreen={isFullscreen} - isDark={isDark} - /> - - - - updateConfig({ coloredNumerals: checked })} - isFullscreen={isFullscreen} - isDark={isDark} - /> - - - - updateConfig({ soundEnabled: checked })} - isFullscreen={isFullscreen} - isDark={isDark} - /> - - - {config.soundEnabled && ( - - 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 - /> - - )} - - - - updateAbacusSettings({ nativeAbacusNumbers: checked }) - } - isFullscreen={isFullscreen} - isDark={isDark} - /> - - - -
- - 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()} - /> - - {config.physicalAbacusColumns} - -
-

- For camera vision detection -

-
-
- +
diff --git a/apps/web/src/components/AbacusDock.tsx b/apps/web/src/components/AbacusDock.tsx index 64f5026e..deb3ec21 100644 --- a/apps/web/src/components/AbacusDock.tsx +++ b/apps/web/src/components/AbacusDock.tsx @@ -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, 'children'> { /** Optional identifier for debugging */ @@ -22,6 +22,8 @@ export interface AbacusDockProps extends Omit, '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(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 diff --git a/apps/web/src/components/AppNavBar.tsx b/apps/web/src/components/AppNavBar.tsx index 5c16d896..3f9fd690 100644 --- a/apps/web/src/components/AppNavBar.tsx +++ b/apps/web/src/components/AppNavBar.tsx @@ -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({ )} - {/* Column 2: Style + Language + Theme */} + {/* Column 2: Settings + Developer */}
- {/* Style Section */} -
Abacus Style
+ {/* Settings Link */} +
Settings
-
- + { + 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)' + }} + > + ⚙️ + Preferences + + + {/* Theme Toggle - Quick Access */} +
+
-
- - {/* Language Section */} -
Language
- - - -
- - {/* Theme Section */} -
Theme
- -
- + {/* Abacus Style - Quick Access */} +
+ 🧮 + + Abacus Style + +
{/* Developer Section - shown in dev or when ?debug=1 is used */} @@ -330,6 +361,58 @@ function MenuContent({ <>
Developer
+ { + 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)' + }} + > + 🛠️ + Debug Hub + + { + 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)' + }} + > + 👁️ + Vision Training +
{ @@ -424,37 +507,120 @@ function MenuContent({ - {/* Style Section */} -
Abacus Style
+ {/* Settings Section */} +
Settings
-
- + + { + 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)' + }} + > + ⚙️ + Preferences + + + + {/* Theme Toggle - Quick Access */} +
+
- - - {/* Language Section */} -
Language
- - - - - - {/* Theme Section */} -
Theme
- - e.preventDefault()} style={{ padding: '0 6px' }}> - - + {/* Abacus Style - Quick Access */} +
+ 🧮 + + Abacus Style + + +
{/* Developer Section - shown in dev or when ?debug=1 is used */} {isDebugAllowed && ( <>
Developer
+ + { + 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)' + }} + > + 🛠️ + Debug Hub + + + + { + 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)' + }} + > + 👁️ + Vision Training + + { @@ -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 ( - + + {/* Right side: Undock button (hidden when dock.hideUndock is true) */} + {!dock.hideUndock && ( + + )}
)}
, diff --git a/apps/web/src/components/PageWithNav.tsx b/apps/web/src/components/PageWithNav.tsx index 2dc37748..0e19fa60 100644 --- a/apps/web/src/components/PageWithNav.tsx +++ b/apps/web/src/components/PageWithNav.tsx @@ -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 @@ -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 ( <> - + {children} ) diff --git a/apps/web/src/contexts/MyAbacusContext.tsx b/apps/web/src/contexts/MyAbacusContext.tsx index b0658db7..030d9aeb 100644 --- a/apps/web/src/contexts/MyAbacusContext.tsx +++ b/apps/web/src/contexts/MyAbacusContext.tsx @@ -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>) => 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(null) + useEffect(() => { + dockRef.current = dock + }, [dock]) + // Vision state const [visionConfig, setVisionConfig] = useState(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>) => { + 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,