| import { useState, useEffect, useRef } from 'react'; |
| import { |
| getSettings, saveSettings, saveBackgroundImage, |
| SettingsType, HighlighterRule, exportFullWorkspace, importWorkspace, |
| resetBackgroundImage |
| } from '../../lib/db'; |
| import ReactCrop, { Crop } from 'react-image-crop'; |
| import 'react-image-crop/dist/ReactCrop.css'; |
| import { |
| Settings01Icon, Add01Icon, Delete01Icon, |
| Download01Icon, Upload01Icon, Copy01Icon |
| } from 'hugeicons-react'; |
|
|
| function extractTextFromLexical(jsonString?: string) { |
| if (!jsonString) return ''; |
| try { |
| const root = JSON.parse(jsonString).root; |
| let text = ''; |
| function traverse(node: any) { |
| if (node.type === 'text') text += node.text; |
| if (node.type === 'linebreak') text += '\n'; |
| if (node.children) { |
| node.children.forEach(traverse); |
| if (node.type === 'paragraph') text += '\n'; |
| } |
| } |
| traverse(root); |
| return text.trim(); |
| } catch { return ''; } |
| } |
|
|
| export default function SettingsModal({ onClose }: { onClose: () => void }) { |
| const [settings, setSettings] = useState<SettingsType | null>(null); |
| const [imgSrc, setImgSrc] = useState(''); |
| const [crop, setCrop] = useState<Crop>(); |
| const [importText, setImportText] = useState(''); |
| const [showImport, setShowImport] = useState(false); |
| const imgRef = useRef<HTMLImageElement>(null); |
|
|
| useEffect(() => { |
| getSettings().then(setSettings); |
| }, []); |
|
|
| const handleChangeCryptography = async (font: any) => { |
| if (!settings) return; |
| const newSettings = { ...settings, typography: font }; |
| setSettings(newSettings); |
| await saveSettings(newSettings); |
| window.location.reload(); |
| }; |
|
|
| const handleChangeOpacity = async (field: 'bgOpacity' | 'fgOpacity', val: number) => { |
| if (!settings) return; |
| const newSettings = { ...settings, [field]: val }; |
| setSettings(newSettings); |
| await saveSettings(newSettings); |
| document.documentElement.style.setProperty(`--${field === 'bgOpacity' ? 'bg' : 'fg'}-opacity`, val.toString()); |
| }; |
|
|
| const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files && e.target.files.length > 0) { |
| const reader = new FileReader(); |
| reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || '')); |
| reader.readAsDataURL(e.target.files[0]); |
| } |
| }; |
|
|
| const completeImageSave = async () => { |
| if (imgSrc && imgRef.current && crop) { |
| const canvas = document.createElement('canvas'); |
| const scaleX = imgRef.current.naturalWidth / imgRef.current.width; |
| const scaleY = imgRef.current.naturalHeight / imgRef.current.height; |
| canvas.width = crop.width; |
| canvas.height = crop.height; |
| const ctx = canvas.getContext('2d'); |
|
|
| if (ctx) { |
| ctx.drawImage( |
| imgRef.current, |
| crop.x * scaleX, |
| crop.y * scaleY, |
| crop.width * scaleX, |
| crop.height * scaleY, |
| 0, |
| 0, |
| crop.width, |
| crop.height |
| ); |
| const base64Image = canvas.toDataURL('image/jpeg'); |
| await saveBackgroundImage(base64Image); |
| window.location.reload(); |
| } |
| } else if (imgSrc) { |
| await saveBackgroundImage(imgSrc); |
| window.location.reload(); |
| } |
| }; |
|
|
| const handleResetBackground = async () => { |
| await resetBackgroundImage(); |
| window.location.reload(); |
| }; |
|
|
| const handleUpdateRule = async (id: string, updates: Partial<HighlighterRule>) => { |
| if (!settings) return; |
| const newRules = settings.highlighters.map(r => r.id === id ? { ...r, ...updates } : r); |
| const newSettings = { ...settings, highlighters: newRules }; |
| setSettings(newSettings); |
| await saveSettings(newSettings); |
| }; |
|
|
| const handleAddRule = async () => { |
| if (!settings) return; |
| const newRule: HighlighterRule = { |
| id: Math.random().toString(36).substring(2, 9), |
| openSymbol: '(', closeSymbol: ')', color: '#ffb3ba', |
| baseOpacity: 0.1, stackMode: 'larger-lighter' |
| }; |
| const newSettings = { ...settings, highlighters: [...settings.highlighters, newRule] }; |
| setSettings(newSettings); |
| await saveSettings(newSettings); |
| }; |
|
|
| const handleDeleteRule = async (id: string) => { |
| if (!settings) return; |
| const newSettings = { ...settings, highlighters: settings.highlighters.filter(r => r.id !== id) }; |
| setSettings(newSettings); |
| await saveSettings(newSettings); |
| }; |
|
|
| const handleExport = async () => { |
| const data = await exportFullWorkspace(); |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `workspace-backup-${new Date().toISOString().split('T')[0]}.json`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| }; |
|
|
| const handleCopyCleanWorkspace = async () => { |
| const data = await exportFullWorkspace(); |
| let textOut = '--- WORKSPACE EXPORT ---\n\n'; |
| data.files.forEach(f => { |
| if (f.type === 'file') { |
| textOut += `[FILE: ${f.name}]\n`; |
| textOut += extractTextFromLexical(f.content); |
| textOut += '\n\n'; |
| } |
| }); |
| await navigator.clipboard.writeText(textOut); |
| alert('Clean textual workspace copied to clipboard!'); |
| }; |
|
|
| const handleImport = async () => { |
| try { |
| const data = JSON.parse(importText); |
| if (data.files && data.settings) { |
| await importWorkspace(data); |
| alert('Import successful! Reloading...'); |
| window.location.reload(); |
| } else { |
| alert('Invalid workspace JSON format.'); |
| } |
| } catch (e) { |
| alert('Failed to parse JSON.'); |
| } |
| }; |
|
|
| if (!settings) return null; |
|
|
| return ( |
| <div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} style={{ |
| position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, |
| backgroundColor: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(12px)', zIndex: 1000, |
| display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' |
| }}> |
| <div className="modal-content" style={{ |
| background: 'rgba(255, 255, 255, 0.95)', |
| backdropFilter: 'blur(20px)', |
| padding: '36px', |
| borderRadius: 'var(--radius-sm)', |
| width: '100%', maxWidth: '640px', |
| maxHeight: '85vh', overflowY: 'auto', |
| boxShadow: '0 40px 80px rgba(0,0,0,0.15)', |
| border: '1px solid rgba(255,255,255,0.4)', |
| fontFamily: 'var(--font-ibm-plex)' |
| }}> |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px', borderBottom: '1px solid rgba(0,0,0,0.05)', paddingBottom: '16px' }}> |
| <h2 style={{ fontSize: '22px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '12px', letterSpacing: '-0.02em' }}> |
| <Settings01Icon size={28} /> System Settings |
| </h2> |
| <button onClick={onClose} style={{ opacity: 0.4, fontSize: '13px', background: 'rgba(0,0,0,0.05)', padding: '6px 12px', borderRadius: '4px' }}>ESC</button> |
| </div> |
| |
| {/* Persistence Section */} |
| <div style={{ marginBottom: '40px' }}> |
| <h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', opacity: 0.4, marginBottom: '16px', letterSpacing: '0.05em' }}>Data Management</h4> |
| <div style={{ display: 'flex', gap: '8px' }}> |
| <button onClick={handleExport} style={{ flex: 1, padding: '12px', background: '#111', color: '#fff', borderRadius: '4px', fontSize: '13px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', border: '1px solid #111' }}> |
| <Download01Icon size={16} /> JSON Backup |
| </button> |
| <button onClick={handleCopyCleanWorkspace} style={{ flex: 1, padding: '12px', background: '#fff', color: '#111', borderRadius: '4px', fontSize: '13px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', border: '1px solid #ddd' }}> |
| <Copy01Icon size={16} /> Copy Clean Text |
| </button> |
| <button onClick={() => setShowImport(!showImport)} style={{ flex: 1, padding: '12px', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', fontSize: '13px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}> |
| <Upload01Icon size={16} /> Import JSON |
| </button> |
| </div> |
| {showImport && ( |
| <div style={{ marginTop: '12px' }}> |
| <textarea |
| placeholder="Paste workspace JSON here..." |
| value={importText} |
| onChange={e => setImportText(e.target.value)} |
| style={{ width: '100%', height: '120px', padding: '16px', borderRadius: '4px', border: '1px solid #ddd', fontSize: '12px', outline: 'none', background: '#fcfcfc', resize: 'vertical' }} |
| /> |
| <button onClick={handleImport} style={{ marginTop: '8px', width: '100%', padding: '12px', background: 'var(--accent-color)', color: '#fff', borderRadius: '4px', fontSize: '14px', fontWeight: 500 }}>Restore Workspace</button> |
| </div> |
| )} |
| </div> |
| |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '40px' }}> |
| <div> |
| <h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', opacity: 0.4, marginBottom: '12px', letterSpacing: '0.05em' }}>Typography</h4> |
| <select |
| value={settings.typography} |
| onChange={e => handleChangeCryptography(e.target.value)} |
| style={{ padding: '12px 16px', width: '100%', border: '1px solid #ddd', borderRadius: '4px', outline: 'none', fontSize: '14px', background: '#fcfcfc', appearance: 'none' }} |
| > |
| <option value="IBM Plex Mono">IBM Plex Mono</option> |
| <option value="ClaudeSans">ClaudeSans (Inter)</option> |
| <option value="WixMadeForDisplay">WixMadeForDisplay</option> |
| <option value="CourierNew">CourierNew</option> |
| </select> |
| </div> |
| |
| <div> |
| <h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', opacity: 0.4, marginBottom: '12px', letterSpacing: '0.05em' }}>Opacities</h4> |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> |
| <span style={{ fontSize: '12px', width: '90px' }}>Background</span> |
| <input type="range" min="0" max="1" step="0.05" value={settings.bgOpacity ?? 0.15} onChange={e => handleChangeOpacity('bgOpacity', parseFloat(e.target.value))} style={{ flex: 1 }} /> |
| <span style={{ fontSize: '12px', opacity: 0.5, width: '30px', textAlign: 'right' }}>{Math.round((settings.bgOpacity ?? 0.15) * 100)}%</span> |
| </div> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> |
| <span style={{ fontSize: '12px', width: '90px' }}>Foreground</span> |
| <input type="range" min="0" max="1" step="0.05" value={settings.fgOpacity ?? 1} onChange={e => handleChangeOpacity('fgOpacity', parseFloat(e.target.value))} style={{ flex: 1 }} /> |
| <span style={{ fontSize: '12px', opacity: 0.5, width: '30px', textAlign: 'right' }}>{Math.round((settings.fgOpacity ?? 1) * 100)}%</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div style={{ marginBottom: '40px' }}> |
| <h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', opacity: 0.4, marginBottom: '16px', letterSpacing: '0.05em' }}>Visual Background</h4> |
| <div style={{ background: '#fcfcfc', padding: '24px', borderRadius: '4px', border: '1px solid #ddd' }}> |
| <input type="file" accept="image/*" onChange={handleImageUpload} style={{ fontSize: '13px', marginBottom: '16px' }} /> |
| {imgSrc && ( |
| <ReactCrop crop={crop} onChange={c => setCrop(c)}> |
| <img ref={imgRef} src={imgSrc} style={{ width: '100%', borderRadius: '4px', display: 'block' }} alt="Background preview" /> |
| </ReactCrop> |
| )} |
| <div style={{ display: 'flex', gap: '8px', marginTop: imgSrc ? '16px' : '0' }}> |
| <button onClick={completeImageSave} style={{ flex: 2, padding: '10px', background: '#111', color: '#fff', borderRadius: '4px', fontSize: '13px' }}>Set Background</button> |
| <button onClick={handleResetBackground} style={{ flex: 1, padding: '10px', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', fontSize: '13px' }}>Clear</button> |
| </div> |
| </div> |
| </div> |
| |
| <div> |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> |
| <h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', opacity: 0.4, letterSpacing: '0.05em' }}>Auto-Highlighter Rules</h4> |
| <button onClick={handleAddRule} style={{ fontSize: '12px', background: '#111', color: '#fff', padding: '6px 12px', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '6px' }}> |
| <Add01Icon size={14} /> Add Pattern |
| </button> |
| </div> |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> |
| {settings.highlighters.map(hl => ( |
| <div key={hl.id} style={{ |
| padding: '16px', background: '#fcfcfc', |
| borderRadius: '4px', border: '1px solid #eee', |
| display: 'grid', gridTemplateColumns: 'minmax(0, 1.5fr) minmax(0, 1.5fr) minmax(0, 2fr) auto', gap: '16px', alignItems: 'end' |
| }}> |
| <div> |
| <label style={{ fontSize: '10px', fontWeight: 600, opacity: 0.4, display: 'block', marginBottom: '6px' }}>SYMBOLS</label> |
| <div style={{ display: 'flex', alignItems: 'center', background: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '4px 8px' }}> |
| <input value={hl.openSymbol} onChange={e => handleUpdateRule(hl.id, { openSymbol: e.target.value })} style={{ width: '100%', textAlign: 'center', border: 'none', background: 'transparent', outline: 'none', fontSize: '13px' }} placeholder="Open" /> |
| <span style={{ opacity: 0.2 }}>|</span> |
| <input value={hl.closeSymbol} onChange={e => handleUpdateRule(hl.id, { closeSymbol: e.target.value })} style={{ width: '100%', textAlign: 'center', border: 'none', background: 'transparent', outline: 'none', fontSize: '13px' }} placeholder="Close" /> |
| </div> |
| </div> |
| <div> |
| <label style={{ fontSize: '10px', fontWeight: 600, opacity: 0.4, display: 'block', marginBottom: '6px' }}>COLOR & OPACITY</label> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '2px 8px' }}> |
| <input type="color" value={hl.color} onChange={e => handleUpdateRule(hl.id, { color: e.target.value })} style={{ width: '24px', height: '24px', border: 'none', padding: 0, background: 'none' }} /> |
| <div style={{ width: '1px', height: '16px', background: '#ddd' }} /> |
| <input type="number" min="1" max="100" value={Math.round(hl.baseOpacity * 100)} onChange={e => handleUpdateRule(hl.id, { baseOpacity: parseInt(e.target.value)/100 })} style={{ width: '100%', border: 'none', background: 'transparent', outline: 'none', fontSize: '13px' }} /> |
| <span style={{ fontSize: '11px', opacity: 0.4 }}>%</span> |
| </div> |
| </div> |
| <div> |
| <label style={{ fontSize: '10px', fontWeight: 600, opacity: 0.4, display: 'block', marginBottom: '6px' }}>NESTING LOGIC</label> |
| <select value={hl.stackMode} onChange={e => handleUpdateRule(hl.id, { stackMode: e.target.value as any })} style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', border: '1px solid #ddd', background: '#fff', outline: 'none', fontSize: '12px' }}> |
| <option value="larger-lighter">Reverse Direction (Larger = Lighter)</option> |
| <option value="smaller-lighter">Default Direction (Smaller = Lighter)</option> |
| </select> |
| </div> |
| <button onClick={() => handleDeleteRule(hl.id)} style={{ padding: '8px', color: '#ff4d4f', background: 'rgba(255, 77, 79, 0.1)', borderRadius: '4px', display: 'flex' }}> |
| <Delete01Icon size={16} /> |
| </button> |
| </div> |
| ))} |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|