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:
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal file
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
135
calcpad-web/src/hooks/useTheme.ts
Normal file
135
calcpad-web/src/hooks/useTheme.ts
Normal 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})`
|
||||
}
|
||||
Reference in New Issue
Block a user