feat(web): implement complete workspace with themes, tabs, sidebar, and mobile

Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:12:05 -04:00
parent 806e2f1ec6
commit 0d38bd3108
78 changed files with 8175 additions and 421 deletions

View File

@@ -0,0 +1,357 @@
import { useState, useCallback, useRef } from 'react'
const STORAGE_KEY = 'calctext-documents'
const FOLDERS_KEY = 'calctext-folders'
const TABS_KEY = 'calctext-tabs'
const ACTIVE_KEY = 'calctext-active-tab'
const SIDEBAR_KEY = 'calctext-sidebar'
export interface CalcDocument {
id: string
title: string
content: string
folderId: string | null
isFavorite: boolean
createdAt: string
updatedAt: string
}
export interface CalcFolder {
id: string
name: string
parentId: string | null
order: number
}
interface StoreState {
documents: CalcDocument[]
folders: CalcFolder[]
openTabIds: string[]
activeTabId: string
}
const WELCOME_DOC: CalcDocument = {
id: 'welcome',
title: 'Welcome',
content: `# CalcText
// Basic arithmetic
2 + 3
10 * 4.5
100 / 7
// Variables
price = 49.99
quantity = 3
subtotal = price * quantity
// Percentages
tax = subtotal * 8%
total = subtotal + tax
// Functions
sqrt(144)
2 ^ 10
`,
folderId: null,
isFavorite: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function loadState(): StoreState {
try {
const docsJson = localStorage.getItem(STORAGE_KEY)
const foldersJson = localStorage.getItem(FOLDERS_KEY)
const tabsJson = localStorage.getItem(TABS_KEY)
const activeId = localStorage.getItem(ACTIVE_KEY)
const documents: CalcDocument[] = docsJson ? JSON.parse(docsJson) : [WELCOME_DOC]
const folders: CalcFolder[] = foldersJson ? JSON.parse(foldersJson) : []
const openTabIds: string[] = tabsJson ? JSON.parse(tabsJson) : [documents[0]?.id ?? 'welcome']
const activeTabId = activeId && openTabIds.includes(activeId) ? activeId : openTabIds[0] ?? ''
if (documents.length === 0) documents.push(WELCOME_DOC)
if (openTabIds.length === 0) openTabIds.push(documents[0].id)
return { documents, folders, openTabIds, activeTabId }
} catch {
return { documents: [WELCOME_DOC], folders: [], openTabIds: ['welcome'], activeTabId: 'welcome' }
}
}
function saveState(state: StoreState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.documents))
localStorage.setItem(FOLDERS_KEY, JSON.stringify(state.folders))
localStorage.setItem(TABS_KEY, JSON.stringify(state.openTabIds))
localStorage.setItem(ACTIVE_KEY, state.activeTabId)
} catch { /* */ }
}
export function loadSidebarState(): { visible: boolean; width: number } {
try {
const json = localStorage.getItem(SIDEBAR_KEY)
if (json) return JSON.parse(json)
} catch { /* */ }
return { visible: true, width: 240 }
}
export function saveSidebarState(s: { visible: boolean; width: number }) {
try { localStorage.setItem(SIDEBAR_KEY, JSON.stringify(s)) } catch { /* */ }
}
export function useDocumentStore() {
const [state, setState] = useState<StoreState>(loadState)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Use a ref to always have latest state for return values
const stateRef = useRef(state)
stateRef.current = state
const persist = useCallback((next: StoreState) => {
setState(next)
saveState(next)
}, [])
const persistDebounced = useCallback((next: StoreState) => {
setState(next)
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveState(next), 300)
}, [])
// Use functional updates to avoid stale closures
const setActiveTab = useCallback((id: string) => {
setState(prev => {
const next = { ...prev, activeTabId: id }
saveState(next)
return next
})
}, [])
const createDocument = useCallback((title?: string, content?: string): CalcDocument => {
const id = generateId()
const now = new Date().toISOString()
// Read current state to count untitled
const cur = stateRef.current
const existingUntitled = cur.documents.filter(d => d.title.startsWith('Untitled')).length
const newDoc: CalcDocument = {
id,
title: title ?? (existingUntitled === 0 ? 'Untitled' : `Untitled ${existingUntitled + 1}`),
content: content ?? '',
folderId: null,
isFavorite: false,
createdAt: now,
updatedAt: now,
}
setState(prev => {
const next: StoreState = {
...prev,
documents: [...prev.documents, newDoc],
openTabIds: [...prev.openTabIds, id],
activeTabId: id,
}
saveState(next)
return next
})
return newDoc
}, [])
const updateContent = useCallback((id: string, content: string) => {
setState(prev => {
const next: StoreState = {
...prev,
documents: prev.documents.map(d =>
d.id === id ? { ...d, content, updatedAt: new Date().toISOString() } : d
),
}
// debounced save
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveState(next), 300)
return next
})
}, [])
const renameDocument = useCallback((id: string, title: string) => {
setState(prev => {
const next: StoreState = {
...prev,
documents: prev.documents.map(d =>
d.id === id ? { ...d, title: title.trim() || 'Untitled', updatedAt: new Date().toISOString() } : d
),
}
saveState(next)
return next
})
}, [])
const closeTab = useCallback((id: string) => {
setState(prev => {
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
let nextActive = prev.activeTabId
if (nextTabs.length === 0) {
const newId = generateId()
const newDoc: CalcDocument = {
id: newId, title: 'Untitled', content: '',
folderId: null, isFavorite: false,
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}
const next: StoreState = {
...prev,
documents: [...prev.documents, newDoc],
openTabIds: [newId],
activeTabId: newId,
}
saveState(next)
return next
}
if (nextActive === id) {
const closedIdx = prev.openTabIds.indexOf(id)
nextActive = nextTabs[Math.min(closedIdx, nextTabs.length - 1)]
}
const next = { ...prev, openTabIds: nextTabs, activeTabId: nextActive }
saveState(next)
return next
})
}, [])
const deleteDocument = useCallback((id: string) => {
setState(prev => {
const nextDocs = prev.documents.filter(d => d.id !== id)
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
let nextActive = prev.activeTabId
if (nextDocs.length === 0) {
const wd = { ...WELCOME_DOC, id: generateId() }
nextDocs.push(wd)
}
if (nextTabs.length === 0) nextTabs = [nextDocs[0].id]
if (!nextTabs.includes(nextActive)) nextActive = nextTabs[0]
const next: StoreState = { ...prev, documents: nextDocs, openTabIds: nextTabs, activeTabId: nextActive }
saveState(next)
return next
})
}, [])
const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setState(prev => {
const tabs = [...prev.openTabIds]
const [moved] = tabs.splice(fromIndex, 1)
tabs.splice(toIndex, 0, moved)
const next = { ...prev, openTabIds: tabs }
saveState(next)
return next
})
}, [])
const openDocument = useCallback((id: string) => {
setState(prev => {
const next = prev.openTabIds.includes(id)
? { ...prev, activeTabId: id }
: { ...prev, openTabIds: [...prev.openTabIds, id], activeTabId: id }
saveState(next)
return next
})
}, [])
const toggleFavorite = useCallback((id: string) => {
setState(prev => {
const next: StoreState = {
...prev,
documents: prev.documents.map(d =>
d.id === id ? { ...d, isFavorite: !d.isFavorite } : d
),
}
saveState(next)
return next
})
}, [])
const moveToFolder = useCallback((docId: string, folderId: string | null) => {
setState(prev => {
const next: StoreState = {
...prev,
documents: prev.documents.map(d =>
d.id === docId ? { ...d, folderId } : d
),
}
saveState(next)
return next
})
}, [])
const createFolder = useCallback((name?: string, parentId?: string | null): CalcFolder => {
const folder: CalcFolder = {
id: generateId(),
name: name ?? 'New Folder',
parentId: parentId ?? null,
order: stateRef.current.folders.length,
}
setState(prev => {
const next = { ...prev, folders: [...prev.folders, folder] }
saveState(next)
return next
})
return folder
}, [])
const renameFolder = useCallback((id: string, name: string) => {
setState(prev => {
const next: StoreState = {
...prev,
folders: prev.folders.map(f =>
f.id === id ? { ...f, name: name.trim() || 'Folder' } : f
),
}
saveState(next)
return next
})
}, [])
const deleteFolder = useCallback((id: string) => {
setState(prev => {
const next: StoreState = {
...prev,
documents: prev.documents.map(d => d.folderId === id ? { ...d, folderId: null } : d),
folders: prev.folders.filter(f => f.id !== id && f.parentId !== id),
}
saveState(next)
return next
})
}, [])
const activeDoc = state.documents.find(d => d.id === state.activeTabId) ?? state.documents[0]
const openDocs = state.openTabIds
.map(id => state.documents.find(d => d.id === id))
.filter((d): d is CalcDocument => d != null)
return {
documents: state.documents,
folders: state.folders,
openDocs,
openTabIds: state.openTabIds,
activeTabId: state.activeTabId,
activeDoc,
setActiveTab,
createDocument,
updateContent,
renameDocument,
closeTab,
deleteDocument,
reorderTabs,
openDocument,
toggleFavorite,
moveToFolder,
createFolder,
renameFolder,
deleteFolder,
}
}

View File

@@ -0,0 +1,135 @@
const STORAGE_KEY = 'calctext-theme'
const ACCENT_KEY = 'calctext-accent'
export type ThemeId = 'system' | 'light' | 'dark' | 'matrix' | 'midnight' | 'warm'
export const THEMES: { id: ThemeId; name: string; icon: string }[] = [
{ id: 'light', name: 'Light', icon: '☀️' },
{ id: 'dark', name: 'Dark', icon: '🌙' },
{ id: 'matrix', name: 'Matrix', icon: '💻' },
{ id: 'midnight', name: 'Midnight', icon: '🌊' },
{ id: 'warm', name: 'Warm', icon: '📜' },
]
export const ACCENT_COLORS = [
{ name: 'Indigo', light: '#6366f1', dark: '#818cf8' },
{ name: 'Teal', light: '#14b8a6', dark: '#2dd4bf' },
{ name: 'Rose', light: '#f43f5e', dark: '#fb7185' },
{ name: 'Amber', light: '#f59e0b', dark: '#fbbf24' },
{ name: 'Emerald', light: '#10b981', dark: '#34d399' },
{ name: 'Sky', light: '#0ea5e9', dark: '#38bdf8' },
]
function getStoredTheme(): ThemeId {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && ['system', 'light', 'dark', 'matrix', 'midnight', 'warm'].includes(stored)) {
return stored as ThemeId
}
} catch { /* localStorage unavailable */ }
return 'system'
}
function getStoredAccent(): string | null {
try {
return localStorage.getItem(ACCENT_KEY)
} catch { return null }
}
function resolveSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function isDarkTheme(theme: ThemeId): boolean {
if (theme === 'system') return resolveSystemTheme() === 'dark'
return theme === 'dark' || theme === 'matrix' || theme === 'midnight'
}
import { useState, useEffect, useCallback } from 'react'
export function useTheme() {
const [theme, setThemeState] = useState<ThemeId>(getStoredTheme)
const [accentColor, setAccentState] = useState<string | null>(getStoredAccent)
const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme
const setTheme = useCallback((id: ThemeId) => {
setThemeState(id)
try { localStorage.setItem(STORAGE_KEY, id) } catch { /* */ }
applyTheme(id, accentColor)
}, [accentColor])
const setAccent = useCallback((color: string | null) => {
setAccentState(color)
try {
if (color) localStorage.setItem(ACCENT_KEY, color)
else localStorage.removeItem(ACCENT_KEY)
} catch { /* */ }
applyTheme(theme, color)
}, [theme])
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyTheme('system', accentColor)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [theme, accentColor])
// Apply on mount
useEffect(() => {
applyTheme(theme, accentColor)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return {
theme,
resolvedTheme,
setTheme,
accentColor,
setAccent,
isDark: isDarkTheme(theme),
themes: THEMES,
accentColors: ACCENT_COLORS,
}
}
function applyTheme(theme: ThemeId, accent: string | null) {
const resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme
document.documentElement.setAttribute('data-theme', resolved)
// Apply custom accent if set
if (accent) {
const dark = isDarkTheme(theme)
document.documentElement.style.setProperty('--accent', accent)
document.documentElement.style.setProperty('--accent-bg', hexToRgba(accent, dark ? 0.15 : 0.1))
document.documentElement.style.setProperty('--accent-border', hexToRgba(accent, 0.5))
} else {
document.documentElement.style.removeProperty('--accent')
document.documentElement.style.removeProperty('--accent-bg')
document.documentElement.style.removeProperty('--accent-border')
}
// Update PWA theme-color
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) {
const bgColors: Record<string, string> = {
light: '#ffffff',
dark: '#16171d',
matrix: '#0a0a0a',
midnight: '#0f172a',
warm: '#fffbf5',
}
meta.setAttribute('content', bgColors[resolved] ?? '#ffffff')
}
}
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}