SPB Git

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%
5.1 KB · 139 lines javascript
Raw Blame History
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