SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
8.3 KB · 200 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/web/assets/js/app.js8 *  Purpose : Vanilla JS layer — theme, copy, filters, anchors, mermaid9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213(function () {14  'use strict';1516  /* ── Theme toggle ─────────────────────────── */17  var toggle = document.getElementById('theme-toggle');18  if (toggle) {19    toggle.addEventListener('click', function () {20      var html = document.documentElement;21      var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';22      html.setAttribute('data-theme', next);23      try { localStorage.setItem('spbgit-theme', next); } catch (e) { /* ignore */ }24    });25  }2627  /* ── "/" focuses the global search ────────── */28  document.addEventListener('keydown', function (event) {29    if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return;30    var target = event.target;31    if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;32    var input = document.getElementById('global-search');33    if (input) {34      event.preventDefault();35      input.focus();36      input.select();37    }38  });3940  /* ── Copy buttons ─────────────────────────── */41  document.addEventListener('click', function (event) {42    var btn = event.target.closest('.copy-btn');43    if (!btn) return;44    var text = btn.getAttribute('data-copy');45    if (!text) return;46    navigator.clipboard.writeText(text).then(function () {47      btn.classList.add('copied');48      var prev = btn.textContent;49      btn.textContent = '✓';50      setTimeout(function () {51        btn.classList.remove('copied');52        btn.textContent = prev;53      }, 1200);54    });55  });5657  /* ── Copy code blocks (fences + blob view) ── */58  document.addEventListener('click', function (event) {59    var btn = event.target.closest('[data-code-copy]');60    if (!btn) return;61    var scope = btn.getAttribute('data-code-target');62    var container = scope ? document.querySelector(scope) : btn.closest('.code-block');63    var pre = container ? container.querySelector('pre') : null;64    if (!pre) return;65    // Strip line-number anchors from the copied text.66    var clone = pre.cloneNode(true);67    clone.querySelectorAll('a.ln').forEach(function (a) { a.remove(); });68    navigator.clipboard.writeText(clone.textContent).then(function () {69      btn.classList.add('copied');70      var label = btn.querySelector('.code-copy-label');71      var prev = label ? label.textContent : null;72      if (label) label.textContent = 'Copied!';73      setTimeout(function () {74        btn.classList.remove('copied');75        if (label && prev) label.textContent = prev;76      }, 1400);77    });78  });7980  /* ── Home: filter + sort repo list ────────── */81  var list = document.getElementById('repo-list');82  if (list) {83    var filterInput = document.getElementById('repo-filter');84    var sortSelect = document.getElementById('repo-sort');85    var langSelect = document.getElementById('repo-lang');86    var topicSelect = document.getElementById('repo-topic');87    var emptyNote = document.getElementById('repo-list-empty');8889    var apply = function () {90      var q = (filterInput.value || '').toLowerCase();91      var lang = langSelect.value;92      var topic = topicSelect.value;93      var cards = Array.prototype.slice.call(list.querySelectorAll('.repo-card'));94      var visible = 0;95      cards.forEach(function (card) {96        var name = card.getAttribute('data-name') || '';97        var desc = (card.getAttribute('data-description') || '').toLowerCase();98        var cardLang = card.getAttribute('data-language') || '';99        var topics = (card.getAttribute('data-topics') || '').split(',');100        var show =101          (!q || name.toLowerCase().indexOf(q) !== -1 || desc.indexOf(q) !== -1) &&102          (!lang || cardLang === lang) &&103          (!topic || topics.indexOf(topic) !== -1);104        card.hidden = !show;105        if (show) visible += 1;106      });107      var key = sortSelect.value;108      var attr = key === 'name' ? 'data-name' : key === 'created' ? 'data-created' : 'data-pushed';109      cards110        .sort(function (a, b) {111          var av = a.getAttribute(attr) || '';112          var bv = b.getAttribute(attr) || '';113          return key === 'name' ? av.localeCompare(bv) : bv.localeCompare(av);114        })115        .forEach(function (card) { list.appendChild(card); });116      if (emptyNote) emptyNote.hidden = visible > 0;117    };118    [filterInput, sortSelect, langSelect, topicSelect].forEach(function (el) {119      if (!el) return;120      el.addEventListener('input', apply);121      el.addEventListener('change', apply);122    });123  }124125  /* ── Ref selector navigation ──────────────── */126  var refSelect = document.getElementById('ref-select');127  if (refSelect) {128    refSelect.addEventListener('change', function () {129      var repo = refSelect.getAttribute('data-repo');130      var kind = refSelect.getAttribute('data-kind') || 'tree';131      var path = refSelect.getAttribute('data-path') || '';132      var ref = refSelect.value;133      var url = '/' + repo + '/' + kind + '/' + ref + (path ? '/' + path : '');134      location.href = url;135    });136  }137138  /* ── Line range highlighting (#L10-L20) ───── */139  var codeView = document.getElementById('code-view');140  if (codeView) {141    var applyRange = function () {142      codeView.querySelectorAll('.line.range-hl').forEach(function (el) {143        el.classList.remove('range-hl');144      });145      var match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);146      if (!match) return;147      var start = parseInt(match[1], 10);148      var end = match[2] ? parseInt(match[2], 10) : start;149      if (end < start) { var t = start; start = end; end = t; }150      for (var i = start; i <= end; i += 1) {151        var line = document.getElementById('L' + i);152        if (line) line.classList.add('range-hl');153      }154      var first = document.getElementById('L' + start);155      if (first && match[2]) first.scrollIntoView({ block: 'center' });156    };157    applyRange();158    window.addEventListener('hashchange', applyRange);159    // Shift-click a line number to extend the selection into a range.160    codeView.addEventListener('click', function (event) {161      var anchor = event.target.closest('a.ln');162      if (!anchor || !event.shiftKey) return;163      event.preventDefault();164      var current = /^#L(\d+)/.exec(location.hash);165      var clicked = /^#?L?(\d+)/.exec(anchor.getAttribute('href').slice(1));166      if (current && clicked) {167        history.replaceState(null, '', '#L' + current[1] + '-L' + clicked[1]);168        applyRange();169      }170    });171  }172173  /* ── Mermaid: lazy-load only when present ─── */174  var mermaidBlocks = document.querySelectorAll('.mermaid-block');175  if (mermaidBlocks.length > 0) {176    var script = document.createElement('script');177    script.src = '/assets/vendor/mermaid.min.js';178    script.onload = function () {179      if (!window.mermaid) return;180      var isDark = document.documentElement.getAttribute('data-theme') === 'dark';181      window.mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict' });182      mermaidBlocks.forEach(function (block, index) {183        var src = block.querySelector('.mermaid-src');184        var target = block.querySelector('.mermaid-target');185        if (!src || !target) return;186        window.mermaid187          .render('mermaid-svg-' + index, src.textContent)188          .then(function (result) {189            target.innerHTML = result.svg; // mermaid output, securityLevel strict190            block.setAttribute('data-rendered', '1');191          })192          .catch(function () {193            block.setAttribute('data-rendered', '0');194          });195      });196    };197    document.body.appendChild(script);198  }199})();200