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:
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mobile results tray — replaces the side panel on small screens.
|
||||
* Collapsed: shows last result + drag handle (48px).
|
||||
* Expanded: scrollable list of all results (40vh).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/mobile-results-tray.css'
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set([
|
||||
'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
|
||||
])
|
||||
|
||||
interface MobileResultsTrayProps {
|
||||
results: EngineLineResult[]
|
||||
docLines: string[]
|
||||
}
|
||||
|
||||
export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
const startY = useRef<number | null>(null)
|
||||
|
||||
// Find last displayable result
|
||||
let lastResult = ''
|
||||
for (let i = results.length - 1; i >= 0; i--) {
|
||||
if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
|
||||
lastResult = results[i].display
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
|
||||
const text = rawValue != null ? String(rawValue) : display
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx)
|
||||
setTimeout(() => setCopiedIdx(null), 1200)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Touch swipe handling
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY
|
||||
}, [])
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (startY.current === null) return
|
||||
const deltaY = startY.current - e.changedTouches[0].clientY
|
||||
startY.current = null
|
||||
if (deltaY > 40) setExpanded(true) // swipe up
|
||||
if (deltaY < -40) setExpanded(false) // swipe down
|
||||
}, [])
|
||||
|
||||
// Build result items for expanded view
|
||||
const resultItems = results.map((r, i) => {
|
||||
if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
|
||||
const expr = docLines[i]?.trim() ?? ''
|
||||
const isCopied = copiedIdx === i
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
|
||||
onClick={() => handleCopy(i, r.rawValue, r.display)}
|
||||
>
|
||||
<span className="tray-result-line">Ln {i + 1}</span>
|
||||
<span className="tray-result-expr">{expr}</span>
|
||||
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
|
||||
</div>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Drag handle + collapsed view */}
|
||||
<div
|
||||
className="tray-header"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<div className="tray-drag-handle" />
|
||||
{!expanded && (
|
||||
<span className="tray-last-result">
|
||||
{lastResult ? `Last: ${lastResult}` : 'No results'}
|
||||
</span>
|
||||
)}
|
||||
{expanded && (
|
||||
<span className="tray-last-result">{resultItems.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="tray-content">
|
||||
{resultItems.length > 0 ? resultItems : (
|
||||
<div className="tray-empty">No results yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user