PHP Classes

File: public_html/webdoors/gemini-capsule/js/app.js

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   public_html/webdoors/gemini-capsule/js/app.js   Download  
File: public_html/webdoors/gemini-capsule/js/app.js
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 13,908 bytes
 

Contents

Class file image Download
/** * Gemini Capsule WebDoor ? Editor * * Split-pane gemtext editor with live preview. * Depends on: gemtext.js (from gemini-browser, loaded before this file) */ const GeminiCapsule = (() => { 'use strict'; // ?? State ????????????????????????????????????????????????????????????????? let currentFile = null; // filename string or null let isPublished = false; let isDirty = false; let capsuleUrl = ''; let previewDebounce = null; // ?? DOM refs ?????????????????????????????????????????????????????????????? const el = {}; function bindRefs() { el.fileList = document.getElementById('file-list'); el.btnNewFile = document.getElementById('btn-new-file'); el.newFileForm = document.getElementById('new-file-form'); el.newFilenameInput= document.getElementById('new-filename-input'); el.btnCreateConfirm= document.getElementById('btn-create-confirm'); el.btnCreateCancel = document.getElementById('btn-create-cancel'); el.editorArea = document.getElementById('editor-area'); el.editorEmpty = document.getElementById('editor-empty'); el.editingFilename = document.getElementById('editing-filename'); el.textarea = document.getElementById('editor-textarea'); el.previewPane = document.getElementById('preview-pane'); el.btnSave = document.getElementById('btn-save'); el.btnPublish = document.getElementById('btn-publish'); el.btnDelete = document.getElementById('btn-delete'); el.capsuleUrlLink = document.getElementById('capsule-url-link'); el.toastContainer = document.getElementById('toast-container'); el.btnCheatsheet = document.getElementById('btn-cheatsheet'); el.cheatsheet = document.getElementById('cheatsheet'); } // ?? API helpers ??????????????????????????????????????????????????????????? async function apiGet(action, params = {}) { const qs = new URLSearchParams({ action, ...params }); const res = await fetch(`api.php?${qs}`); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } async function apiPost(action, body = {}) { const res = await fetch(`api.php?action=${encodeURIComponent(action)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } // ?? Toast notifications ??????????????????????????????????????????????????? function toast(message, type = 'info') { const t = document.createElement('div'); t.className = `toast toast-${type}`; t.textContent = message; el.toastContainer.appendChild(t); setTimeout(() => t.classList.add('toast-show'), 10); setTimeout(() => { t.classList.remove('toast-show'); setTimeout(() => t.remove(), 300); }, 3000); } // ?? File list ????????????????????????????????????????????????????????????? async function loadFileList(selectFilename = null) { try { const data = await apiGet('file_list'); renderFileList(data.files || [], selectFilename); } catch (err) { toast('Failed to load files: ' + err.message, 'error'); } } function renderFileList(files, selectFilename) { el.fileList.innerHTML = ''; if (files.length === 0) { const li = document.createElement('li'); li.className = 'file-list-empty'; li.textContent = 'No files yet'; el.fileList.appendChild(li); return; } files.forEach(file => { const li = document.createElement('li'); li.className = 'file-item' + (file.filename === currentFile ? ' active' : ''); li.dataset.filename = file.filename; const dot = document.createElement('span'); dot.className = 'publish-dot ' + (file.is_published ? 'published' : 'draft'); dot.title = file.is_published ? 'Published' : 'Draft'; const name = document.createElement('span'); name.className = 'file-name'; name.textContent = file.filename; li.appendChild(dot); li.appendChild(name); li.addEventListener('click', () => openFile(file.filename)); el.fileList.appendChild(li); }); if (selectFilename) { openFile(selectFilename); } } // ?? Open / close file ????????????????????????????????????????????????????? async function openFile(filename) { if (isDirty && currentFile) { if (!confirm(`You have unsaved changes in "${currentFile}". Discard them?`)) { return; } } try { const data = await apiGet('file_load', { filename }); currentFile = data.filename; isPublished = data.is_published; isDirty = false; el.editorEmpty.classList.add('hidden'); el.editorArea.classList.remove('hidden'); el.editingFilename.textContent = currentFile; el.textarea.value = data.content; updatePublishButton(); updatePreview(); highlightActive(filename); } catch (err) { toast('Failed to load file: ' + err.message, 'error'); } } function highlightActive(filename) { document.querySelectorAll('#file-list .file-item').forEach(li => { li.classList.toggle('active', li.dataset.filename === filename); }); } // ?? Save ?????????????????????????????????????????????????????????????????? async function saveFile() { if (!currentFile) return; el.btnSave.disabled = true; try { await apiPost('file_save', { filename: currentFile, content: el.textarea.value, }); isDirty = false; toast(`"${currentFile}" saved`, 'success'); await loadFileList(null); highlightActive(currentFile); } catch (err) { toast('Save failed: ' + err.message, 'error'); } finally { el.btnSave.disabled = false; } } // ?? Publish toggle ???????????????????????????????????????????????????????? async function togglePublish() { if (!currentFile) return; const newState = !isPublished; el.btnPublish.disabled = true; try { await apiPost('file_publish', { filename: currentFile, is_published: newState, }); isPublished = newState; updatePublishButton(); const label = newState ? 'published' : 'unpublished'; toast(`"${currentFile}" ${label}`, 'success'); await loadFileList(null); highlightActive(currentFile); } catch (err) { toast('Failed to update publish status: ' + err.message, 'error'); } finally { el.btnPublish.disabled = false; } } function updatePublishButton() { el.btnPublish.textContent = isPublished ? 'Unpublish' : 'Publish'; el.btnPublish.classList.toggle('btn-published', isPublished); } // ?? Delete ???????????????????????????????????????????????????????????????? async function deleteFile() { if (!currentFile) return; if (!confirm(`Delete "${currentFile}"? This cannot be undone.`)) { return; } el.btnDelete.disabled = true; try { await apiPost('file_delete', { filename: currentFile }); toast(`"${currentFile}" deleted`, 'info'); currentFile = null; isPublished = false; isDirty = false; el.editorArea.classList.add('hidden'); el.editorEmpty.classList.remove('hidden'); await loadFileList(); } catch (err) { toast('Delete failed: ' + err.message, 'error'); } finally { el.btnDelete.disabled = false; } } // ?? New file ?????????????????????????????????????????????????????????????? function showNewFileForm() { el.newFileForm.classList.remove('hidden'); el.newFilenameInput.value = ''; el.newFilenameInput.focus(); } function hideNewFileForm() { el.newFileForm.classList.add('hidden'); el.newFilenameInput.value = ''; } async function createNewFile() { let filename = el.newFilenameInput.value.trim(); if (!filename) return; // Auto-append .gmi if no extension given if (!filename.includes('.')) { filename += '.gmi'; } if (!/^[a-zA-Z0-9_\-]+\.(gmi|gemini)$/.test(filename)) { toast('Invalid filename ? use letters, numbers, dashes, underscores with .gmi or .gemini extension', 'error'); return; } try { await apiPost('file_save', { filename, content: '' }); hideNewFileForm(); await loadFileList(filename); } catch (err) { toast('Failed to create file: ' + err.message, 'error'); } } // ?? Preview ??????????????????????????????????????????????????????????????? function updatePreview() { const content = el.textarea.value; const baseUrl = capsuleUrl || 'gemini://localhost/'; el.previewPane.innerHTML = renderGemtext(content, baseUrl); } function schedulePreview() { clearTimeout(previewDebounce); previewDebounce = setTimeout(updatePreview, 300); } // ?? Capsule URL ??????????????????????????????????????????????????????????? async function loadCapsuleUrl() { try { const data = await apiGet('capsule_url'); capsuleUrl = data.url || ''; el.capsuleUrlLink.textContent = capsuleUrl; el.capsuleUrlLink.href = capsuleUrl; } catch (err) { el.capsuleUrlLink.textContent = 'unavailable'; } } // ?? Keyboard shortcuts ???????????????????????????????????????????????????? function handleKeydown(e) { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } } // ?? Init ?????????????????????????????????????????????????????????????????? async function init() { bindRefs(); // Buttons el.btnNewFile.addEventListener('click', showNewFileForm); el.btnCreateConfirm.addEventListener('click', createNewFile); el.btnCreateCancel.addEventListener('click', hideNewFileForm); el.btnSave.addEventListener('click', saveFile); el.btnPublish.addEventListener('click', togglePublish); el.btnDelete.addEventListener('click', deleteFile); el.btnCheatsheet.addEventListener('click', () => { const visible = !el.cheatsheet.classList.contains('hidden'); el.cheatsheet.classList.toggle('hidden', visible); el.btnCheatsheet.classList.toggle('active', !visible); }); // New file: Enter to confirm, Escape to cancel el.newFilenameInput.addEventListener('keydown', e => { if (e.key === 'Enter') createNewFile(); if (e.key === 'Escape') hideNewFileForm(); }); // Editor el.textarea.addEventListener('input', () => { isDirty = true; schedulePreview(); }); document.addEventListener('keydown', handleKeydown); // Load initial data await Promise.all([loadCapsuleUrl(), loadFileList()]); } return { init }; })(); document.addEventListener('DOMContentLoaded', () => GeminiCapsule.init());