feature: alte Versionen direkt auf openbureau anzeigen
- API (öffentlich): /api/history listet Git-Versionen eines Beitrags, /api/history/version rendert eine alte Fassung (marked + Fußnoten-Support), on-demand via git im CMS-Container — kein Vorbauen. Pfad/rev validiert. - Versions-Marke neben dem Kopf jedes Library-Beitrags (zeigt bewusst die Fassung); öffnet den Verlauf, Auswahl ersetzt den Text + Rücksprung-Banner. - CSS für Badge/Panel/Banner; marked als Dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1529,3 +1529,71 @@ img:hover { filter: grayscale(0%); }
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Versions-Marke + Verlauf — alte Fassungen direkt auf der Seite anzeigen.
|
||||
------------------------------------------------------------------------ */
|
||||
.version-line { margin: 0.5rem 0 0; }
|
||||
.version-badge {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.12em 0.7em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.version-badge:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.version-badge[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.version-panel {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.version-list { list-style: none; margin: 0; padding: 0; }
|
||||
.version-list button {
|
||||
display: flex;
|
||||
gap: 0.9em;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.45em 0.6em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.version-list button:hover { background: var(--color-bg-primary); }
|
||||
.version-list .v-date { white-space: nowrap; }
|
||||
.version-list .v-subject { flex: 1; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.version-list .v-hash { white-space: nowrap; color: var(--color-text-muted); }
|
||||
.version-empty { margin: 0.4rem 0.6rem; font-size: var(--font-size-small); color: var(--color-text-muted); }
|
||||
|
||||
.version-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: 0.5em 0.8em;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--color-bg-secondary);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.version-back {
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.version-loading { color: var(--color-text-muted); font-style: italic; }
|
||||
|
||||
Generated
+13
@@ -12,6 +12,7 @@
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
@@ -672,6 +673,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.1.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
|
||||
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import history from './routes/history.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './routes/dialog.js';
|
||||
@@ -65,6 +66,8 @@ app.get('/api/forums', listForums);
|
||||
app.get('/api/forums/:slug', showForum);
|
||||
app.get('/api/recent', recent);
|
||||
app.get('/api/thread', threadInfo);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
|
||||
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Hono } from 'hono';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import { safeRel } from '../files.js';
|
||||
|
||||
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
|
||||
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
|
||||
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
|
||||
const execFileP = promisify(execFile);
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
|
||||
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
|
||||
|
||||
const history = new Hono();
|
||||
|
||||
// Liste der Versionen: neueste zuerst.
|
||||
history.get('/', async (c) => {
|
||||
let rel;
|
||||
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||
try {
|
||||
const { stdout } = await git(
|
||||
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
|
||||
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
||||
const [rev, short, date, author, subject] = line.split(US);
|
||||
return { rev, short, date, author, subject };
|
||||
});
|
||||
return c.json(versions);
|
||||
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
|
||||
});
|
||||
|
||||
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
|
||||
history.get('/version', async (c) => {
|
||||
let rel;
|
||||
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||
const rev = c.req.query('rev') || '';
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
|
||||
try {
|
||||
const { stdout } = await git('show', `${rev}:content/${rel}`);
|
||||
const { data, content } = matter(stdout);
|
||||
return c.json({
|
||||
rev,
|
||||
title: data.title || '',
|
||||
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
|
||||
html: renderMarkdown(content),
|
||||
});
|
||||
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
|
||||
});
|
||||
|
||||
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
|
||||
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
|
||||
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
|
||||
function renderMarkdown(md) {
|
||||
const defs = {}; const order = [];
|
||||
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
|
||||
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
|
||||
if (!order.includes(id)) order.push(id);
|
||||
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
|
||||
});
|
||||
let html = marked.parse(md);
|
||||
if (order.length) {
|
||||
html += '<div class="footnotes"><ol>'
|
||||
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
|
||||
+ '</ol></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
export default history;
|
||||
@@ -40,6 +40,15 @@
|
||||
{{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}<span class="lastmod">Aktualisiert am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
{{/* Versions-Marke: macht bewusst, dass dies eine bestimmte Fassung ist,
|
||||
und öffnet den Verlauf (alte Fassungen direkt auf der Seite). */}}
|
||||
{{ if and (eq .Section "library") .GitInfo }}
|
||||
<p class="version-line">
|
||||
<button type="button" id="version-badge" class="version-badge" aria-expanded="false"
|
||||
data-path="{{ .File.Path }}">Version {{ .GitInfo.AbbreviatedHash }}</button>
|
||||
</p>
|
||||
{{ end }}
|
||||
</header>
|
||||
|
||||
{{/* Table of Contents */}}
|
||||
@@ -66,6 +75,7 @@
|
||||
{{/* Herkunft/Zitieren — nur bei Library-Beiträgen (lebendes Dokument). */}}
|
||||
{{ if eq .Section "library" }}
|
||||
{{ partial "provenance.html" . }}
|
||||
<script src="/version-history.js"></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* Dialog nur bei Artikeln (Library), nicht auf Seiten wie Spenden/Manifest. */}}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* OPENBUREAU — Versionsverlauf eines Beitrags direkt auf der Seite.
|
||||
Die Marke „Version xxx" neben dem Kopf öffnet die Liste der Fassungen
|
||||
(aus /api/history); Auswahl ersetzt den Beitragstext mit der alten Fassung
|
||||
(aus /api/history/version) und zeigt einen Banner mit Rücksprung. */
|
||||
(function () {
|
||||
var badge = document.getElementById('version-badge');
|
||||
if (!badge) return;
|
||||
var path = badge.dataset.path;
|
||||
var content = document.querySelector('.single-content');
|
||||
var article = document.querySelector('article.single');
|
||||
if (!path || !content || !article) return;
|
||||
|
||||
var originalHTML = content.innerHTML;
|
||||
var originalLabel = badge.textContent;
|
||||
var panel = null, banner = null;
|
||||
|
||||
function api(p) {
|
||||
return fetch(p).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function fmt(d) { try { return new Date(d).toLocaleDateString('de-CH'); } catch (e) { return d || ''; } }
|
||||
|
||||
function closePanel() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
badge.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
function restore() {
|
||||
content.innerHTML = originalHTML;
|
||||
badge.textContent = originalLabel;
|
||||
if (banner) { banner.remove(); banner = null; }
|
||||
}
|
||||
function showBanner(v) {
|
||||
if (banner) banner.remove();
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'version-banner';
|
||||
banner.append('Ältere Fassung vom ' + fmt(v.date) + ' · Version ' + v.short + ' ');
|
||||
var back = document.createElement('button');
|
||||
back.type = 'button'; back.className = 'version-back'; back.textContent = '→ Zur aktuellen Fassung';
|
||||
back.addEventListener('click', restore);
|
||||
banner.appendChild(back);
|
||||
article.insertBefore(banner, article.firstChild);
|
||||
}
|
||||
function loadVersion(v) {
|
||||
closePanel();
|
||||
content.innerHTML = '<p class="version-loading">Lade Fassung …</p>';
|
||||
api('/api/history/version?path=' + encodeURIComponent(path) + '&rev=' + encodeURIComponent(v.rev)).then(function (data) {
|
||||
if (!data || !data.html) { restore(); return; }
|
||||
content.innerHTML = data.html;
|
||||
badge.textContent = 'Version ' + v.short;
|
||||
showBanner(v);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
function openPanel() {
|
||||
api('/api/history?path=' + encodeURIComponent(path)).then(function (list) {
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'version-panel';
|
||||
if (!list || !list.length) {
|
||||
panel.innerHTML = '<p class="version-empty">Kein Verlauf verfügbar.</p>';
|
||||
} else {
|
||||
var ol = document.createElement('ol');
|
||||
ol.className = 'version-list';
|
||||
list.forEach(function (v, i) {
|
||||
var li = document.createElement('li');
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
var date = document.createElement('span'); date.className = 'v-date'; date.textContent = fmt(v.date);
|
||||
var subj = document.createElement('span'); subj.className = 'v-subject'; subj.textContent = v.subject || '';
|
||||
var hash = document.createElement('span'); hash.className = 'v-hash'; hash.textContent = v.short + (i === 0 ? ' · aktuell' : '');
|
||||
b.append(date, subj, hash);
|
||||
if (i === 0) b.addEventListener('click', function () { restore(); closePanel(); });
|
||||
else b.addEventListener('click', function () { loadVersion(v); });
|
||||
li.appendChild(b);
|
||||
ol.appendChild(li);
|
||||
});
|
||||
panel.appendChild(ol);
|
||||
}
|
||||
badge.parentNode.insertBefore(panel, badge.nextSibling);
|
||||
badge.setAttribute('aria-expanded', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
badge.addEventListener('click', function () { panel ? closePanel() : openPanel(); });
|
||||
})();
|
||||
Reference in New Issue
Block a user