spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/assets/js/editor.js8 * Purpose : In-browser text editor overlay — edit code/markdown/text files,9 * ⌘S saves as a new version10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { h, fmtSize, toast } from './ui.js';15import { UI } from './icons.js';1617const EDITABLE_STRATEGIES = new Set(['code', 'markdown', 'structured', 'csv']);18const EDIT_SIZE_LIMIT = 4 * 1024 * 1024;1920/** Can this node be opened in the text editor? */21export function isEditable(node) {22 return node.type === 'file'23 && EDITABLE_STRATEGIES.has(node.strategy)24 && node.size <= EDIT_SIZE_LIMIT;25}2627/** Full-screen editor overlay for one file node. */28export class Editor {29 /** @param {object} node @param {{onSaved?: (node) => void}} opts */30 constructor(node, opts = {}) {31 this.node = node;32 this.opts = opts;33 this.dirty = false;34 this.saving = false;35 this.build();36 this.load();37 }3839 build() {40 this.textarea = h('textarea.editor-text', {41 spellcheck: 'false', autocapitalize: 'off', autocomplete: 'off',42 placeholder: 'Loading…', disabled: true,43 });44 this.status = h('span.editor-status', {}, 'Loading…');45 this.saveBtn = h('button.btn.primary', {46 onclick: () => this.save(),47 }, 'Save');48 this.overlay = h('div.preview-overlay.editor-overlay', { role: 'dialog', 'aria-label': 'Editor' },49 h('div.preview-head', {},50 h('span.title', {}, this.node.name),51 h('span.size', {}, fmtSize(this.node.size)),52 h('span.editor-dirty', { style: { display: 'none' } }, '● unsaved'),53 h('div.p-actions', {},54 this.status,55 this.saveBtn,56 h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }))),57 h('div.editor-body', {}, this.textarea));5859 this.textarea.addEventListener('input', () => this.markDirty(true));60 this.textarea.addEventListener('keydown', (e) => {61 // Tab inserts a real tab instead of moving focus.62 if (e.key === 'Tab') {63 e.preventDefault();64 const { selectionStart: s, selectionEnd: eEnd, value } = this.textarea;65 this.textarea.value = `${value.slice(0, s)}\t${value.slice(eEnd)}`;66 this.textarea.selectionStart = this.textarea.selectionEnd = s + 1;67 this.markDirty(true);68 }69 });70 this.onKey = (e) => {71 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {72 e.preventDefault();73 this.save();74 return;75 }76 if (e.key === 'Escape') {77 e.stopPropagation();78 this.close();79 }80 };81 document.addEventListener('keydown', this.onKey, true);82 document.body.append(this.overlay);83 }8485 async load() {86 try {87 const res = await fetch(`/api/v1/preview/${this.node.id}/raw`, { credentials: 'same-origin' });88 if (!res.ok) throw new Error(`HTTP ${res.status}`);89 this.textarea.value = await res.text();90 this.textarea.disabled = false;91 this.textarea.focus();92 this.markDirty(false);93 } catch (err) {94 this.status.textContent = `Could not load — ${err.message}`;95 }96 }9798 markDirty(dirty) {99 this.dirty = dirty;100 this.overlay.querySelector('.editor-dirty').style.display = dirty ? '' : 'none';101 const lines = (this.textarea.value.match(/\n/g)?.length ?? 0) + 1;102 this.status.textContent = `${lines} lines · ${fmtSize(new Blob([this.textarea.value]).size)}`;103 }104105 async save() {106 if (this.saving || this.textarea.disabled) return;107 this.saving = true;108 this.saveBtn.disabled = true;109 this.status.textContent = 'Saving…';110 try {111 const res = await fetch(`/api/v1/nodes/${this.node.id}/content`, {112 method: 'PUT',113 headers: { 'content-type': 'text/plain', 'x-spbdrive-csrf': '1' },114 body: this.textarea.value,115 credentials: 'same-origin',116 });117 const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;118 if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`);119 this.node = data.node;120 this.markDirty(false);121 toast('Saved — previous content kept in version history');122 this.opts.onSaved?.(data.node);123 } catch (err) {124 toast(`Save failed — ${err.message}`, { error: true });125 this.status.textContent = 'Save failed';126 } finally {127 this.saving = false;128 this.saveBtn.disabled = false;129 }130 }131132 close() {133 if (this.dirty && !window.confirm('Discard unsaved changes?')) return;134 document.removeEventListener('keydown', this.onKey, true);135 this.overlay.remove();136 this.opts.onClose?.();137 }138}139