PHP Classes

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

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   public_html/webdoors/gemini-browser/js/app.js   Download  
File: public_html/webdoors/gemini-browser/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: 15,882 bytes
 

Contents

Class file image Download
/** * app.js ? Gemini Browser WebDoor * * Handles navigation, page rendering, input prompts, and bookmarks. * Depends on gemtext.js (renderGemtext, resolveGeminiUrl, escapeHtml). */ 'use strict'; const GeminiBrowser = (() => { // ?? State ????????????????????????????????????????????????????????????????? let navHistory = []; // visited URLs let histPos = -1; // current position in navHistory let currentUrl = ''; let bookmarks = []; let config = { home_url: 'about:home' }; let pendingInputUrl = ''; // URL awaiting user input (status 1x) // ?? DOM refs ?????????????????????????????????????????????????????????????? let el = {}; // ?? Initialise ???????????????????????????????????????????????????????????? async function init() { el = { urlBar: document.getElementById('url-bar'), btnBack: document.getElementById('btn-back'), btnForward: document.getElementById('btn-forward'), btnHome: document.getElementById('btn-home'), btnGo: document.getElementById('btn-go'), btnBmToggle: document.getElementById('btn-bm-toggle'), btnPanel: document.getElementById('btn-panel'), contentWrap: document.getElementById('content-wrap'), content: document.getElementById('content'), loading: document.getElementById('loading'), panel: document.getElementById('bookmarks-panel'), bmList: document.getElementById('bookmarks-list'), btnClosePanel: document.getElementById('btn-close-panel'), statusText: document.getElementById('status-text'), inputModal: document.getElementById('input-modal'), inputLabel: document.getElementById('input-prompt-label'), inputField: document.getElementById('input-field'), btnInputOk: document.getElementById('btn-input-submit'), btnInputCancel:document.getElementById('btn-input-cancel'), }; bindEvents(); // Load config then bookmarks, then navigate home await loadConfig(); await loadBookmarks(); navigate(config.home_url); } // ?? Event bindings ???????????????????????????????????????????????????????? function bindEvents() { el.btnBack.addEventListener('click', goBack); el.btnForward.addEventListener('click', goForward); el.btnHome.addEventListener('click', () => navigate(config.home_url)); el.btnGo.addEventListener('click', navigateToBar); el.urlBar.addEventListener('keydown', e => { if (e.key === 'Enter') navigateToBar(); }); el.btnBmToggle.addEventListener('click', toggleBookmark); el.btnPanel.addEventListener('click', togglePanel); el.btnClosePanel.addEventListener('click', closePanel); // Intercept clicks on gemini:// links inside the content pane el.content.addEventListener('click', e => { const link = e.target.closest('a[data-url]'); if (!link) return; const url = link.dataset.url; if (url && url.startsWith('gemini://')) { e.preventDefault(); navigate(url); } // Non-gemini links already have target="_blank", browser handles them }); el.btnInputOk.addEventListener('click', submitInput); el.btnInputCancel.addEventListener('click', dismissInput); el.inputField.addEventListener('keydown', e => { if (e.key === 'Enter') submitInput(); if (e.key === 'Escape') dismissInput(); }); } // ?? Navigation ???????????????????????????????????????????????????????????? function navigateToBar() { let url = el.urlBar.value.trim(); if (!url) return; // Be forgiving: add scheme if missing (but leave about: URLs alone) if (!url.includes('://') && !url.startsWith('about:')) url = 'gemini://' + url; navigate(url); } /** * Navigate to a URL. * @param {string} url * @param {boolean} push - Whether to push onto history (false when going back/forward) */ async function navigate(url, push = true) { if (!url) return; currentUrl = url; el.urlBar.value = url; setStatus('Loading \u2026'); setLoading(true); updateBmButton(); if (push) { navHistory = navHistory.slice(0, histPos + 1); navHistory.push(url); histPos = navHistory.length - 1; } updateNavButtons(); try { const res = await fetchPage(url); handleResponse(res, url); } catch (err) { showError('Request failed: ' + err.message); } finally { setLoading(false); } } function goBack() { if (histPos > 0) { histPos--; navigate(navHistory[histPos], false); } } function goForward() { if (histPos < navHistory.length - 1) { histPos++; navigate(navHistory[histPos], false); } } function updateNavButtons() { el.btnBack.disabled = histPos <= 0; el.btnForward.disabled = histPos >= navHistory.length - 1; } // ?? Response handling ????????????????????????????????????????????????????? function handleResponse(res, url) { if (!res.success && res.error) { showError(res.error); return; } const status = res.status || 0; const meta = res.meta || ''; // 1x ? Input required if (status >= 10 && status < 20) { showInputPrompt(url, meta, status === 11); return; } // 2x ? Success if (status >= 20 && status < 30) { const rawMime = res.mime || 'text/gemini'; const mime = rawMime.split(';')[0].trim().toLowerCase(); if (mime === 'text/gemini' || mime === '') { renderGemtextPage(res.body || '', url); } else if (mime.startsWith('text/')) { renderPlainText(res.body || ''); } else { showInfo(`This page returned content of type <strong>${escapeHtml(rawMime)}</strong>, which cannot be displayed inline.`); } setStatus(url); return; } // 3x ? Redirect (should have been followed server-side, but handle gracefully) if (status >= 30 && status < 40) { if (meta) { const resolved = resolveGeminiUrl(url, meta); showInfo(`Redirect to <a href="${escapeHtml(resolved)}" class="gmi-a gmi-internal" data-url="${escapeHtml(resolved)}">${escapeHtml(resolved)}</a>`); } else { showWarn(`Redirect (${status}) with no destination.`); } return; } // 4x ? Temporary failure if (status >= 40 && status < 50) { showError(`Temporary failure (${status}): ${escapeHtml(meta || 'No details provided.')}`); return; } // 5x ? Permanent failure if (status >= 50 && status < 60) { showError(`Permanent failure (${status}): ${escapeHtml(meta || 'No details provided.')}`); return; } // 6x ? Client certificate required if (status >= 60 && status < 70) { showWarn(`This page requires a client certificate (${status}: ${escapeHtml(meta || '')}). Client certificates are not currently supported by this browser.`); return; } showError(`Unexpected status code ${status} from server.`); } function renderGemtextPage(body, url) { el.content.innerHTML = `<p class="gmi-meta">${escapeHtml(url)}</p>` + renderGemtext(body, url); el.contentWrap.scrollTop = 0; updateBmButton(); } function renderPlainText(body) { el.content.innerHTML = `<pre class="gmi-pre">${escapeHtml(body)}</pre>`; el.contentWrap.scrollTop = 0; } function showError(html) { el.content.innerHTML = `<div class="gmi-error">\u2717 ${html}</div>`; setStatus('Error'); } function showWarn(html) { el.content.innerHTML = `<div class="gmi-warn">\u26a0 ${html}</div>`; setStatus('Warning'); } function showInfo(html) { el.content.innerHTML = `<div class="gmi-info">${html}</div>`; } function setLoading(show) { el.loading.hidden = !show; if (show) el.content.innerHTML = ''; } function setStatus(msg) { el.statusText.textContent = msg; } // ?? Input prompt (Gemini 1x) ?????????????????????????????????????????????? function showInputPrompt(url, prompt, sensitive) { pendingInputUrl = url; el.inputLabel.textContent = prompt || 'Enter input:'; el.inputField.type = sensitive ? 'password' : 'text'; el.inputField.value = ''; el.inputModal.classList.remove('hidden'); el.inputField.focus(); } function submitInput() { const value = el.inputField.value; el.inputModal.classList.add('hidden'); if (!pendingInputUrl) return; // Per Gemini spec: query = percent-encoded user input const base = pendingInputUrl.split('?')[0]; const queryUrl = base + '?' + encodeURIComponent(value); pendingInputUrl = ''; navigate(queryUrl); } function dismissInput() { el.inputModal.classList.add('hidden'); pendingInputUrl = ''; setStatus('Ready'); } // ?? Bookmarks ????????????????????????????????????????????????????????????? async function loadBookmarks() { try { const res = await apiGet('api.php?action=bookmark_list'); bookmarks = res.bookmarks || []; renderBmList(); } catch (_) { /* non-critical ? continue without bookmarks */ } } function renderBmList() { if (bookmarks.length === 0) { el.bmList.innerHTML = '<div class="bookmarks-empty">No bookmarks yet.</div>'; return; } el.bmList.innerHTML = bookmarks.map((bm, i) => `<div class="bookmark-item" data-url="${escapeHtml(bm.url)}" data-index="${i}"> <span class="bookmark-title" title="${escapeHtml(bm.url)}">${escapeHtml(bm.title)}</span> <button class="bookmark-remove" data-index="${i}" title="Remove">\u00d7</button> </div>` ).join(''); el.bmList.querySelectorAll('.bookmark-item').forEach(item => { item.addEventListener('click', e => { if (e.target.classList.contains('bookmark-remove')) return; navigate(item.dataset.url); }); }); el.bmList.querySelectorAll('.bookmark-remove').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const url = btn.closest('.bookmark-item').dataset.url; await removeBookmark(url); }); }); } async function toggleBookmark() { if (!currentUrl || !currentUrl.startsWith('gemini://')) return; const exists = bookmarks.some(b => b.url === currentUrl); if (exists) { await removeBookmark(currentUrl); } else { await addBookmark(currentUrl); } } async function addBookmark(url) { // Use the page's first h1 as the title, falling back to the URL let title = url; const h1 = el.content.querySelector('.gmi-h1'); if (h1 && h1.textContent.trim()) title = h1.textContent.trim(); try { const res = await apiPost('api.php?action=bookmark_add', { url, title }); bookmarks = res.bookmarks || bookmarks; renderBmList(); updateBmButton(); } catch (_) { /* non-critical */ } } async function removeBookmark(url) { try { const res = await apiPost('api.php?action=bookmark_remove', { url }); bookmarks = res.bookmarks || bookmarks; renderBmList(); updateBmButton(); } catch (_) { /* non-critical */ } } function updateBmButton() { const isGemini = currentUrl && currentUrl.startsWith('gemini://'); const active = isGemini && bookmarks.some(b => b.url === currentUrl); el.btnBmToggle.classList.toggle('active', active); el.btnBmToggle.disabled = !isGemini; el.btnBmToggle.title = active ? 'Remove bookmark' : isGemini ? 'Bookmark this page' : 'Cannot bookmark this page'; } function togglePanel() { el.panel.classList.toggle('hidden'); } function closePanel() { el.panel.classList.add('hidden'); } // ?? API helpers ??????????????????????????????????????????????????????????? async function loadConfig() { try { const res = await apiGet('api.php?action=config'); config = Object.assign(config, res); } catch (_) { /* use defaults */ } } async function fetchPage(url) { // Internal pages are served by the BBS, not fetched over Gemini const endpoint = url === 'about:home' ? 'api.php?action=home_page' : `api.php?${new URLSearchParams({ action: 'fetch', url })}`; const res = await fetch(endpoint); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } return res.json(); } async function apiGet(endpoint) { const res = await fetch(endpoint); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function apiPost(endpoint, data) { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // ?? Public API ???????????????????????????????????????????????????????????? return { init }; })(); document.addEventListener('DOMContentLoaded', () => GeminiBrowser.init());