Files
calctext/calcpad-web/src/App.tsx
C. Cassel ef302ebda9 feat: add auth, real-time collaboration, sharing, font control, and UI fixes
Phase 1 - Bug fixes:
- Fix color labels not showing on active line in format preview
- Replace eye emoji with SVG icon showing clear preview/raw state
- Replace // button with comment icon + better tooltip
- Fix ThemePicker accent colors when using system theme

Phase 2 - Font:
- Load JetBrains Mono via Google Fonts with offline fallback
- Add font size control (A-/A+) with keyboard shortcuts
- Persist font size preference in localStorage

Phase 3 - Auth:
- Supabase-based email/password authentication
- Device session management with configurable password renewal TTL
- AuthModal, UserMenu, SecuritySettings components

Phase 4 - Cloud sync:
- Document metadata sync to Supabase PostgreSQL
- Legacy localStorage migration on first login
- IndexedDB persistence via y-indexeddb

Phase 5 - Real-time collaboration:
- Y.js CRDT integration with CodeMirror 6
- Hocuspocus WebSocket server with JWT auth
- Collaborative cursor awareness
- CollabIndicator component

Phase 6 - Sharing:
- Share links with view/edit permissions
- ShareDialog component with copy-to-clipboard
- Minimal client-side router for /s/{token} URLs

Infrastructure:
- Docker Compose with PostgreSQL, GoTrue, PostgREST, Hocuspocus
- Nginx reverse proxy for all backend services
- SQL migrations with RLS policies
- Production-ready Dockerfile with build args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:21:04 -04:00

406 lines
13 KiB
TypeScript

/**
* CalcText main application component.
*
* Workspace layout: header → tab bar → editor + results panel.
* Multi-document support via document store with localStorage persistence.
*/
import { useCallback, useState, useRef, useEffect } from 'react'
import type { EditorView } from '@codemirror/view'
import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts'
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
import { useTheme } from './hooks/useTheme.ts'
import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
import { OfflineBanner } from './components/OfflineBanner.tsx'
import { InstallPrompt } from './components/InstallPrompt.tsx'
import { ResultsPanel } from './components/ResultsPanel.tsx'
import { ThemePicker } from './components/ThemePicker.tsx'
import { TabBar } from './components/TabBar.tsx'
import { Sidebar } from './components/Sidebar.tsx'
import { StatusBar } from './components/StatusBar.tsx'
import { AlignToolbar } from './components/AlignToolbar.tsx'
import type { Alignment } from './components/AlignToolbar.tsx'
import { FormatToolbar } from './components/FormatToolbar.tsx'
import { FontSizeControl } from './components/FontSizeControl.tsx'
import { useFontSize } from './hooks/useFontSize.ts'
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
import { UserMenu } from './components/UserMenu.tsx'
import { SecuritySettings } from './components/SecuritySettings.tsx'
import { useAuth } from './auth/AuthProvider.tsx'
import { AuthModal } from './auth/AuthModal.tsx'
import './styles/app.css'
function App() {
const engine = useEngine()
const isOnline = useOnlineStatus()
const installPrompt = useInstallPrompt()
const themeCtx = useTheme()
const fontSizeCtx = useFontSize()
const auth = useAuth()
const store = useDocumentStore()
const [showAuthModal, setShowAuthModal] = useState(false)
const [showSecuritySettings, setShowSecuritySettings] = useState(false)
const [editorView, setEditorView] = useState<EditorView | null>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
const [formatPreview, setFormatPreview] = useState(true)
// Sidebar state
const [sidebarState, setSidebarState] = useState(loadSidebarState)
const setSidebarVisible = useCallback((v: boolean) => {
const next = { ...sidebarState, visible: v }
setSidebarState(next)
saveSidebarState(next)
}, [sidebarState])
const setSidebarWidth = useCallback((w: number) => {
const next = { ...sidebarState, width: w }
setSidebarState(next)
saveSidebarState(next)
}, [sidebarState])
// Track a key to force CalcEditor remount on tab switch
const [editorKey, setEditorKey] = useState(store.activeTabId)
// Draggable divider state
const [dividerX, setDividerX] = useState<number | null>(null)
const isDragging = useRef(false)
const handleDocChange = useCallback(
(lines: string[]) => {
engine.evalSheet(lines)
// Persist content
const content = lines.join('\n')
if (store.activeDoc && content !== store.activeDoc.content) {
store.updateContent(store.activeTabId, content)
setModifiedIds(prev => {
const next = new Set(prev)
next.add(store.activeTabId)
return next
})
// Clear modified dot after save debounce
setTimeout(() => {
setModifiedIds(prev => {
const next = new Set(prev)
next.delete(store.activeTabId)
return next
})
}, 500)
}
},
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
)
// Switch tabs
const handleTabClick = useCallback((id: string) => {
if (id === store.activeTabId) return
store.setActiveTab(id)
setEditorKey(id)
}, [store.activeTabId, store.setActiveTab])
// New document
const handleNewTab = useCallback(() => {
const doc = store.createDocument()
setEditorKey(doc.id)
}, [store.createDocument])
// Close tab
const handleTabClose = useCallback((id: string) => {
store.closeTab(id)
// If we closed the active tab, editorKey needs updating
if (id === store.activeTabId) {
// State will update, trigger effect below
}
}, [store.closeTab, store.activeTabId])
// Sync editorKey when activeTabId changes externally (e.g. after close)
useEffect(() => {
if (editorKey !== store.activeTabId) {
setEditorKey(store.activeTabId)
}
}, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
// Apply editor text alignment via CodeMirror
useEffect(() => {
if (!editorView) return
editorView.dom.style.setProperty('--cm-text-align', editorAlign)
}, [editorView, editorAlign])
// Scroll sync: mirror editor scroll position to results panel
useEffect(() => {
if (!editorView) return
const scroller = editorView.scrollDOM
const onScroll = () => {
if (resultsPanelRef.current) {
resultsPanelRef.current.scrollTop = scroller.scrollTop
}
}
scroller.addEventListener('scroll', onScroll, { passive: true })
return () => scroller.removeEventListener('scroll', onScroll)
}, [editorView])
// Draggable divider handlers
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
isDragging.current = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [])
useEffect(() => {
function onMouseMove(e: MouseEvent) {
if (!isDragging.current) return
setDividerX(e.clientX)
}
function onMouseUp() {
if (!isDragging.current) return
isDragging.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
return () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
}, [])
// Keyboard shortcuts
useEffect(() => {
function handleKey(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey
// Ctrl+B — toggle sidebar
if (mod && e.key === 'b') {
e.preventDefault()
setSidebarVisible(!sidebarState.visible)
return
}
// Ctrl+N — new document
if (mod && e.key === 'n') {
e.preventDefault()
handleNewTab()
return
}
// Ctrl+W — close tab
if (mod && e.key === 'w') {
e.preventDefault()
handleTabClose(store.activeTabId)
return
}
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
if (mod && e.key === 'Tab') {
e.preventDefault()
const idx = store.openTabIds.indexOf(store.activeTabId)
const len = store.openTabIds.length
const next = e.shiftKey
? store.openTabIds[(idx - 1 + len) % len]
: store.openTabIds[(idx + 1) % len]
handleTabClick(next)
return
}
// Ctrl+1-9 — jump to tab
if (mod && e.key >= '1' && e.key <= '9') {
e.preventDefault()
const tabIdx = parseInt(e.key) - 1
if (tabIdx < store.openTabIds.length) {
handleTabClick(store.openTabIds[tabIdx])
}
return
}
// Ctrl+= — increase font size
if (mod && (e.key === '=' || e.key === '+')) {
e.preventDefault()
fontSizeCtx.setFontSize(fontSizeCtx.fontSize + 1)
return
}
// Ctrl+- — decrease font size
if (mod && e.key === '-') {
e.preventDefault()
fontSizeCtx.setFontSize(fontSizeCtx.fontSize - 1)
return
}
// Ctrl+0 — reset font size
if (mod && e.key === '0') {
e.preventDefault()
fontSizeCtx.resetFontSize()
return
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible, fontSizeCtx])
// Compute flex styles from divider position
const editorStyle: React.CSSProperties = dividerX !== null
? { width: dividerX, flex: 'none' }
: {}
const resultsStyle: React.CSSProperties = dividerX !== null
? { flex: 1 }
: {}
return (
<div className="calcpad-app">
<OfflineBanner isOnline={isOnline} />
<header className="calcpad-header">
<button
className="header-sidebar-toggle"
onClick={() => setSidebarVisible(!sidebarState.visible)}
title="Toggle sidebar (Ctrl+B)"
aria-label="Toggle sidebar"
>
</button>
<h1>CalcText</h1>
<div className="header-spacer" />
<div className="header-actions">
<FormatToolbar
editorView={editorView}
previewMode={formatPreview}
onPreviewToggle={() => setFormatPreview(p => !p)}
/>
<div className="header-divider" />
<AlignToolbar
editorAlign={editorAlign}
resultsAlign={resultsAlign}
onEditorAlignChange={setEditorAlign}
onResultsAlignChange={setResultsAlign}
/>
<FontSizeControl
fontSize={fontSizeCtx.fontSize}
onFontSizeChange={fontSizeCtx.setFontSize}
min={fontSizeCtx.MIN_SIZE}
max={fontSizeCtx.MAX_SIZE}
/>
<ThemePicker
theme={themeCtx.theme}
resolvedTheme={themeCtx.resolvedTheme}
accentColor={themeCtx.accentColor}
onThemeChange={themeCtx.setTheme}
onAccentChange={themeCtx.setAccent}
/>
<UserMenu
onOpenAuth={() => setShowAuthModal(true)}
onOpenSecurity={() => setShowSecuritySettings(true)}
/>
</div>
</header>
<TabBar
tabs={store.openDocs}
activeTabId={store.activeTabId}
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabRename={store.renameDocument}
onNewTab={handleNewTab}
modifiedIds={modifiedIds}
/>
<div className="calcpad-workspace">
{/* Mobile sidebar backdrop */}
{sidebarState.visible && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarVisible(false)}
/>
)}
<Sidebar
visible={sidebarState.visible}
width={sidebarState.width}
documents={store.documents}
folders={store.folders}
activeTabId={store.activeTabId}
openTabIds={store.openTabIds}
onFileClick={(id) => { store.openDocument(id); setEditorKey(id) }}
onNewDocument={(title, content) => {
const doc = store.createDocument(title, content)
setEditorKey(doc.id)
}}
onNewFolder={() => store.createFolder()}
onRenameDocument={store.renameDocument}
onDeleteDocument={store.deleteDocument}
onToggleFavorite={store.toggleFavorite}
onMoveToFolder={store.moveToFolder}
onRenameFolder={store.renameFolder}
onDeleteFolder={store.deleteFolder}
onWidthChange={setSidebarWidth}
/>
<main className="calcpad-editor">
<div className="editor-pane" style={editorStyle}>
<CalcEditor
key={editorKey}
initialDoc={store.activeDoc?.content ?? ''}
onDocChange={handleDocChange}
results={engine.results}
debounceMs={50}
onViewReady={setEditorView}
formatPreview={formatPreview}
/>
</div>
<div
className="pane-divider"
onMouseDown={onDividerMouseDown}
/>
<ResultsPanel
ref={resultsPanelRef}
results={engine.results}
align={resultsAlign}
style={resultsStyle}
/>
</main>
</div>
<MobileResultsTray
results={engine.results}
docLines={store.activeDoc?.content.split('\n') ?? []}
/>
<StatusBar
editorView={editorView}
engineReady={engine.ready}
lineCount={store.activeDoc?.content.split('\n').length ?? 0}
/>
<InstallPrompt
promptEvent={installPrompt.promptEvent}
isInstalled={installPrompt.isInstalled}
onInstall={installPrompt.handleInstall}
onDismiss={installPrompt.handleDismiss}
/>
{showAuthModal && (
<AuthModal onClose={() => setShowAuthModal(false)} />
)}
{auth.needsPasswordRenewal && (
<AuthModal
onClose={() => auth.clearPasswordRenewal()}
renewalMode
/>
)}
{showSecuritySettings && (
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
)}
</div>
)
}
export default App