rem-notepad / src /components /Settings /SettingsModal.tsx
algorembrant's picture
Upload 31 files
4af09f9 verified
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'; // Add newline for paragraphs
}
}
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>
);
}