// Docs.jsx — Rich Documents & File Storage
(() => {
const { Card, Badge, Avatar } = window.TechyFuelOSDesignSystem_be0222 || {};
// Some legacy files (migrated from the old Supabase storage) were saved with a
// DOUBLE-prefixed URL, e.g. "https://api.techyfuel.com/storage/https://xxx.supabase.co/...".
// That never loads. Unwrap it to the real inner URL so old images/files still show.
function fileUrl(u) {
if (!u) return u;
const i = u.indexOf('http', 5); // a second "http" inside means double-prefixed
if (i > 0) return u.slice(i);
return u;
}
// Error boundary to prevent the whole app crashing on doc-editor errors
class DocErrorBoundary extends React.Component {
constructor(props) { super(props); this.state = { err: null }; }
static getDerivedStateFromError(e) { return { err: e }; }
render() {
if (this.state.err) return (
Document error
{String(this.state.err)}
this.setState({ err: null })}
style={{ marginTop: 16, padding: '8px 20px', background: 'var(--blue-600)', color: 'white', border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
Retry
);
return this.props.children;
}
}
const BLOCK_TYPES = [
{ type: 'paragraph', label: 'Text', icon: 'type' },
{ type: 'h1', label: 'Heading 1', icon: 'heading-1' },
{ type: 'h2', label: 'Heading 2', icon: 'heading-2' },
{ type: 'h3', label: 'Heading 3', icon: 'heading-3' },
{ type: 'bullet', label: 'Bullet list', icon: 'list' },
{ type: 'numbered', label: 'Numbered list', icon: 'list-ordered' },
{ type: 'checklist', label: 'To-do', icon: 'check-square' },
{ type: 'code', label: 'Code block', icon: 'code' },
{ type: 'quote', label: 'Quote', icon: 'quote' },
{ type: 'image', label: 'Image', icon: 'image' },
{ type: 'table', label: 'Table', icon: 'table' },
{ type: 'divider', label: 'Divider', icon: 'minus' },
];
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
function mkBlock(type = 'paragraph') {
const id = uid();
if (type === 'table') return { id, type, rows: [['', ''], ['', '']] };
if (type === 'checklist') return { id, type, text: '', checked: false };
if (type === 'code') return { id, type, code: '', language: 'javascript' };
if (type === 'image') return { id, type, url: '', caption: '' };
if (type === 'divider') return { id, type };
return { id, type, text: '' };
}
// Shared props that block browser extensions (Grammarly, translate, spellcheck)
// from wrapping/modifying textareas, which breaks React's removeChild calls
const NO_EXT = {
spellCheck: false, autoComplete: 'off', autoCorrect: 'off',
autoCapitalize: 'off', translate: 'no',
'data-gramm': 'false', 'data-gramm_editor': 'false',
'data-enable-grammarly': 'false',
};
// ── Single Block ──────────────────────────────────────────────────────────────
function Block({ block, index, onChange, onKeyDown, elRef }) {
const base = { outline: 'none', width: '100%', border: 'none', background: 'transparent', fontFamily: 'var(--font-sans)', resize: 'none', padding: 0 };
function detectMarkdown(val) {
if (val === '# ') return onChange({ ...block, type: 'h1', text: '' });
if (val === '## ') return onChange({ ...block, type: 'h2', text: '' });
if (val === '### ') return onChange({ ...block, type: 'h3', text: '' });
if (val === '- ' || val === '* ') return onChange({ ...block, type: 'bullet', text: '' });
if (val === '1. ') return onChange({ ...block, type: 'numbered', text: '' });
if (val === '[] ' || val === '[ ] ') return onChange({ ...block, type: 'checklist', text: '', checked: false });
if (val === '```') return onChange({ ...block, type: 'code', code: '', language: 'javascript' });
if (val === '> ') return onChange({ ...block, type: 'quote', text: '' });
if (val === '---') return onChange({ ...block, type: 'divider' });
onChange({ ...block, text: val });
}
if (block.type === 'divider') return ;
if (block.type === 'image') {
if (block.url) return (
);
return (
{ const u = prompt('Paste image URL:'); if (u) onChange({ ...block, url: u }); }}>
Click to embed image URL
);
}
if (block.type === 'code') return (
{['#ff5f57','#febc2e','#28c840'].map(c =>
)}
onChange({ ...block, language: e.target.value })}
style={{ background: 'transparent', border: 'none', color: '#8899aa', fontSize: 12, cursor: 'pointer', marginLeft: 8 }}>
{['javascript','typescript','python','html','css','sql','bash','json','go','rust'].map(l => {l} )}
);
if (block.type === 'table') return (
{(block.rows || []).map((row, ri) => (
{row.map((cell, ci) => (
{ const rows = block.rows.map((r, rr) => rr === ri ? r.map((c, cc) => cc === ci ? e.target.value : c) : r); onChange({ ...block, rows }); }}
style={{ ...base, fontSize: 14, padding: '7px 11px', fontWeight: ri === 0 ? 600 : 400 }} />
))}
onChange({ ...block, rows: block.rows.filter((_, i) => i !== ri) })} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: '2px 5px', fontSize: 14 }}>×
))}
onChange({ ...block, rows: [...block.rows, block.rows[0]?.map(() => '') || ['', '']] })} style={{ fontSize: 12, color: 'var(--blue-600)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>+ Add row
onChange({ ...block, rows: block.rows.map(r => [...r, '']) })} style={{ fontSize: 12, color: 'var(--blue-600)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>+ Add column
);
if (block.type === 'checklist') return (
onChange({ ...block, checked: e.target.checked })} style={{ marginTop: 4, accentColor: 'var(--blue-600)', cursor: 'pointer', width: 16, height: 16, flexShrink: 0 }} />
);
const styles = {
paragraph: { fontSize: 15, lineHeight: 1.75, color: 'var(--text-body)' },
h1: { fontSize: 33, fontWeight: 800, lineHeight: 1.2, letterSpacing: '-0.025em', marginTop: 8 },
h2: { fontSize: 25, fontWeight: 700, lineHeight: 1.3, letterSpacing: '-0.015em', marginTop: 6 },
h3: { fontSize: 19, fontWeight: 600, lineHeight: 1.4, marginTop: 4 },
bullet: { fontSize: 15, lineHeight: 1.75, paddingLeft: 18 },
numbered: { fontSize: 15, lineHeight: 1.75, paddingLeft: 22 },
quote: { fontSize: 15, lineHeight: 1.75, borderLeft: '3px solid var(--blue-400)', paddingLeft: 18, color: 'var(--text-muted)', fontStyle: 'italic' },
};
const ph = { paragraph: "Type '/' for commands...", h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', bullet: 'List item', numbered: 'List item', quote: 'Blockquote' };
return (
{block.type === 'bullet' && }
{block.type === 'numbered' && {index + 1}. }
);
}
// ── Slash Command Menu ─────────────────────────────────────────────────────────
function SlashMenu({ pos, query, onSelect, onClose }) {
const filtered = BLOCK_TYPES.filter(b => b.label.toLowerCase().includes((query||'').toLowerCase()) || b.type.includes(query||''));
const [sel, setSel] = React.useState(0);
React.useEffect(() => { setSel(0); }, [query]);
React.useEffect(() => {
function kd(e) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s+1, filtered.length-1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(s-1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[sel]) onSelect(filtered[sel].type); }
else if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', kd, true);
return () => window.removeEventListener('keydown', kd, true);
}, [sel, filtered]);
if (!filtered.length) return null;
return (
Insert block
{filtered.map((b, i) => (
onSelect(b.type)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left', padding: '8px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 14, background: i === sel ? 'var(--blue-50)' : 'transparent', color: i === sel ? 'var(--blue-700)' : 'var(--text-body)' }}>
{b.label}
))}
);
}
// ── Block Editor ──────────────────────────────────────────────────────────────
function BlockEditor({ blocks: rawBlocks, onChange }) {
const blocks = Array.isArray(rawBlocks) ? rawBlocks : [mkBlock()];
const [slash, setSlash] = React.useState(null);
const refs = React.useRef({});
function update(updated) { onChange(blocks.map(b => b.id === updated.id ? updated : b)); }
function handleKD(e, block, idx) {
const val = block.code !== undefined ? (block.code || '') : (block.text || '');
if (e.key === '/' && !val) {
const rect = e.target.getBoundingClientRect();
setSlash({ blockId: block.id, pos: { x: rect.left, y: rect.bottom + 6 }, query: '' });
e.preventDefault(); return;
}
if (slash?.blockId === block.id) {
if (e.key === 'Backspace' && !slash.query) { setSlash(null); return; }
if (e.key.length === 1 && e.key !== '/') { setSlash(s => ({ ...s, query: s.query + e.key })); }
}
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
const nb = mkBlock('paragraph');
const nb2 = [...blocks]; nb2.splice(idx + 1, 0, nb);
onChange(nb2);
setTimeout(() => refs.current[nb.id]?.focus(), 20);
}
if (e.key === 'Backspace' && !val && blocks.length > 1) {
e.preventDefault();
onChange(blocks.filter(b => b.id !== block.id));
setTimeout(() => refs.current[blocks[idx-1]?.id]?.focus(), 20);
}
if (e.key === 'ArrowUp' && idx > 0) refs.current[blocks[idx-1].id]?.focus();
if (e.key === 'ArrowDown' && idx < blocks.length - 1) refs.current[blocks[idx+1].id]?.focus();
}
function insertBlock(type, afterId) {
const idx = blocks.findIndex(b => b.id === afterId);
const cur = blocks[idx];
const isEmpty = cur && cur.type === 'paragraph' && !cur.text;
if (isEmpty) {
const nb = mkBlock(type);
onChange(blocks.map(b => b.id === afterId ? { ...nb, id: b.id } : b));
setTimeout(() => refs.current[afterId]?.focus(), 20);
} else {
const nb = mkBlock(type);
const arr = [...blocks]; arr.splice(idx + 1, 0, nb);
onChange(arr);
setTimeout(() => refs.current[nb.id]?.focus(), 20);
}
setSlash(null);
}
return (
slash && setSlash(null)}>
{blocks.map((b, i) => (
refs.current[b.id] = el} />
))}
{ const nb = mkBlock(); onChange([...blocks, nb]); setTimeout(() => refs.current[nb.id]?.focus(), 20); }}>
Click to keep writing...
{slash &&
insertBlock(t, slash.blockId)} onClose={() => setSlash(null)} />}
);
}
// ── Version History ────────────────────────────────────────────────────────────
function VersionHistory({ docId, onRestore, onClose }) {
const [versions, setVersions] = React.useState([]);
const [sel, setSel] = React.useState(null);
React.useEffect(() => {
if (!window.API || !docId) return;
(async () => { try { const { data } = await window.API.getDocVersions(docId); setVersions(data || []); } catch {} })();
}, [docId]);
return (
Version History
{!versions.length &&
No versions saved yet. Versions are created auto-saved periodically.
}
{versions.map((v, i) => (
setSel(sel?.id === v.id ? null : v)}
style={{ padding: '10px 12px', borderRadius: 8, border: `1px solid ${sel?.id === v.id ? 'var(--blue-400)' : 'var(--slate-200)'}`, marginBottom: 8, cursor: 'pointer', background: sel?.id === v.id ? 'var(--blue-50)' : 'white' }}>
Version {versions.length - i}
{new Date(v.created_at).toLocaleString()}
{sel?.id === v.id && (
{ e.stopPropagation(); onRestore(v.content); }}
style={{ marginTop: 8, fontSize: 12, background: 'var(--blue-600)', color: 'white', border: 'none', borderRadius: 6, padding: '5px 12px', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
Restore this version
)}
))}
);
}
// ── File Preview ───────────────────────────────────────────────────────────────
function FilePreview({ file, onClose }) {
const t = file.file_type || '';
const n = file.name || '';
const drive = isDriveFile(file);
// Drive files only give us a drive.google.com viewer link, not raw file
// bytes — that can't be embedded as //