355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
|
|
const http = require('http');
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
|
||
|
|
const POSTS_DIR = path.join(__dirname, 'content/posts');
|
||
|
|
const PORT = 3002;
|
||
|
|
const STATUS_CLASSES = ['stable', 'corrupted', 'experimental'];
|
||
|
|
|
||
|
|
function esc(s) {
|
||
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
|
}
|
||
|
|
|
||
|
|
function slugify(title) {
|
||
|
|
return String(title)
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[äöü]/g, m => ({ ä: 'ae', ö: 'oe', ü: 'ue' }[m]))
|
||
|
|
.replace(/ß/g, 'ss')
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '') || 'projekt';
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniqueSlug(base) {
|
||
|
|
let slug = base;
|
||
|
|
let i = 2;
|
||
|
|
while (fs.existsSync(path.join(POSTS_DIR, slug + '.md'))) {
|
||
|
|
slug = base + '-' + i;
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
return slug;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── FRONTMATTER PARSEN / SCHREIBEN ──────────────────────────────
|
||
|
|
function parsePost(filename) {
|
||
|
|
const raw = fs.readFileSync(path.join(POSTS_DIR, filename), 'utf8');
|
||
|
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||
|
|
const fm = { title: '', date: '', status_class: 'stable', status_label: '', thumb: '' };
|
||
|
|
let body = raw;
|
||
|
|
if (match) {
|
||
|
|
const fmBlock = match[1];
|
||
|
|
body = match[2].replace(/^\n+/, '');
|
||
|
|
fmBlock.split('\n').forEach(line => {
|
||
|
|
const idx = line.indexOf(':');
|
||
|
|
if (idx === -1) return;
|
||
|
|
const key = line.slice(0, idx).trim();
|
||
|
|
let val = line.slice(idx + 1).trim();
|
||
|
|
val = val.replace(/^['"]|['"]$/g, '');
|
||
|
|
if (key in fm) fm[key] = val;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return { filename, ...fm, body };
|
||
|
|
}
|
||
|
|
|
||
|
|
function serializePost(entry) {
|
||
|
|
const q = v => `'${String(v).replace(/'/g, "''")}'`;
|
||
|
|
return `---
|
||
|
|
title: ${q(entry.title)}
|
||
|
|
date: ${entry.date}
|
||
|
|
status_class: ${q(entry.status_class)}
|
||
|
|
status_label: ${q(entry.status_label)}
|
||
|
|
thumb: ${q(entry.thumb)}
|
||
|
|
---
|
||
|
|
|
||
|
|
${entry.body.trim()}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readAllPosts() {
|
||
|
|
return fs.readdirSync(POSTS_DIR)
|
||
|
|
.filter(f => f.endsWith('.md'))
|
||
|
|
.map(parsePost)
|
||
|
|
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── HTML ─────────────────────────────────────────────────────────
|
||
|
|
function html(posts) {
|
||
|
|
const statusOptions = STATUS_CLASSES.map(c => `<option value="${c}">${c}</option>`).join('');
|
||
|
|
const rows = posts.map(p => `
|
||
|
|
<tr>
|
||
|
|
<td>${esc(p.title)}</td>
|
||
|
|
<td>${esc(p.date)}</td>
|
||
|
|
<td><span class="status status-${esc(p.status_class)}">${esc(p.status_label)}</span></td>
|
||
|
|
<td class="file-cell"><code>${esc(p.filename)}</code></td>
|
||
|
|
<td class="actions">
|
||
|
|
<button onclick="editRow('${esc(p.filename)}')">✏</button>
|
||
|
|
<button class="del" onclick="delRow('${esc(p.filename)}')">✕</button>
|
||
|
|
</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
const json = JSON.stringify(posts).replace(/</g, '\\u003c');
|
||
|
|
|
||
|
|
return `<!DOCTYPE html>
|
||
|
|
<html lang="de">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<title>PROJEKT-ARCHIV // ADMIN</title>
|
||
|
|
<style>
|
||
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
|
body { background: #0a0a0a; color: #d4af37; font-family: 'Courier New', monospace; padding: 30px 20px; }
|
||
|
|
h1 { color: #ff4500; letter-spacing: 8px; font-size: 1.6rem; margin-bottom: 6px; }
|
||
|
|
p.sub { color: rgba(212,175,55,0.4); font-size: 0.7rem; letter-spacing: 3px; margin-bottom: 30px; }
|
||
|
|
|
||
|
|
.box { background: #111; border: 1px solid rgba(184,115,51,0.3); padding: 24px; margin-bottom: 24px; }
|
||
|
|
.box h2 { font-size: 0.85rem; letter-spacing: 4px; color: #b87333; margin-bottom: 18px; }
|
||
|
|
|
||
|
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
|
|
.form-grid .full { grid-column: 1 / -1; }
|
||
|
|
label { display: block; font-size: 0.65rem; letter-spacing: 2px; color: rgba(212,175,55,0.5); margin-bottom: 4px; }
|
||
|
|
input, textarea, select {
|
||
|
|
width: 100%; background: #000; border: 1px solid rgba(184,115,51,0.25);
|
||
|
|
color: #d4af37; font-family: 'Courier New', monospace; font-size: 0.8rem;
|
||
|
|
padding: 8px 10px; outline: none; transition: border-color 0.2s;
|
||
|
|
}
|
||
|
|
input:focus, textarea:focus, select:focus { border-color: #d4af37; }
|
||
|
|
textarea { resize: vertical; min-height: 60px; }
|
||
|
|
textarea#f-body { min-height: 220px; }
|
||
|
|
select option { background: #111; }
|
||
|
|
input:disabled { color: rgba(212,175,55,0.4); border-style: dashed; }
|
||
|
|
|
||
|
|
.btn-row { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
|
||
|
|
button {
|
||
|
|
background: transparent; border: 1px solid rgba(212,175,55,0.4);
|
||
|
|
color: #d4af37; font-family: 'Courier New', monospace; font-size: 0.75rem;
|
||
|
|
letter-spacing: 2px; padding: 8px 20px; cursor: pointer; transition: all 0.2s;
|
||
|
|
}
|
||
|
|
button:hover { background: rgba(212,175,55,0.1); border-color: #d4af37; }
|
||
|
|
button.primary { border-color: #ff4500; color: #ff4500; }
|
||
|
|
button.primary:hover { background: rgba(255,69,0,0.1); }
|
||
|
|
button.del { border-color: rgba(255,69,0,0.3); color: rgba(255,69,0,0.6); padding: 4px 10px; }
|
||
|
|
button.del:hover { background: rgba(255,69,0,0.1); border-color: #ff4500; color: #ff4500; }
|
||
|
|
button.reset-btn { border-color: rgba(212,175,55,0.2); color: rgba(212,175,55,0.4); }
|
||
|
|
|
||
|
|
.table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
|
|
.count { font-size: 0.65rem; color: rgba(212,175,55,0.4); letter-spacing: 2px; }
|
||
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||
|
|
th { text-align: left; color: rgba(212,175,55,0.4); font-size: 0.6rem; letter-spacing: 3px;
|
||
|
|
border-bottom: 1px solid rgba(184,115,51,0.2); padding: 6px 10px; }
|
||
|
|
td { padding: 10px; border-bottom: 1px solid rgba(184,115,51,0.1); vertical-align: top; }
|
||
|
|
td.file-cell code { color: #7ec8a4; font-size: 0.68rem; }
|
||
|
|
tr:hover td { background: rgba(212,175,55,0.03); }
|
||
|
|
td.actions { white-space: nowrap; }
|
||
|
|
td.actions button { padding: 4px 10px; margin-right: 4px; }
|
||
|
|
.status { font-size: 0.6rem; letter-spacing: 2px; padding: 2px 7px; border: 1px solid; white-space: nowrap; }
|
||
|
|
.status-stable { color: #0f0; border-color: #0f0; }
|
||
|
|
.status-corrupted { color: #f00; border-color: #f00; }
|
||
|
|
.status-experimental { color: #888; border-color: #888; }
|
||
|
|
|
||
|
|
.toast { position: fixed; bottom: 30px; right: 30px; background: #111;
|
||
|
|
border: 1px solid #4caf50; color: #4caf50; padding: 10px 20px;
|
||
|
|
font-size: 0.75rem; letter-spacing: 2px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
||
|
|
.toast.err { border-color: #ff4500; color: #ff4500; }
|
||
|
|
.toast.show { opacity: 1; }
|
||
|
|
|
||
|
|
@media (max-width: 700px) {
|
||
|
|
.form-grid { grid-template-columns: 1fr; }
|
||
|
|
table { font-size: 0.68rem; }
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<h1>PROJEKT-ARCHIV</h1>
|
||
|
|
<p class="sub">// LOKALES ADMIN-PANEL · localhost:${PORT} · schreibt content/posts/*.md</p>
|
||
|
|
|
||
|
|
<div class="box">
|
||
|
|
<h2 id="form-title">+ NEUES PROJEKT</h2>
|
||
|
|
<form id="post-form">
|
||
|
|
<input type="hidden" id="edit-filename" value="">
|
||
|
|
<div class="form-grid">
|
||
|
|
<div class="full">
|
||
|
|
<label>TITEL</label>
|
||
|
|
<input type="text" id="f-title" placeholder="z.B. Project: Flux Overdrive" required>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label>DATUM</label>
|
||
|
|
<input type="date" id="f-date" required>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label>STATUS-KLASSE</label>
|
||
|
|
<select id="f-status-class">${statusOptions}</select>
|
||
|
|
</div>
|
||
|
|
<div class="full">
|
||
|
|
<label>STATUS-LABEL (Badge-Text)</label>
|
||
|
|
<input type="text" id="f-status-label" placeholder="z.B. AKTIV" required>
|
||
|
|
</div>
|
||
|
|
<div class="full">
|
||
|
|
<label>THUMBNAIL-PFAD</label>
|
||
|
|
<input type="text" id="f-thumb" placeholder="/img/hero_1.png">
|
||
|
|
</div>
|
||
|
|
<div class="full">
|
||
|
|
<label>DATEINAME (nur bei neuen Projekten, wird sonst nicht geändert)</label>
|
||
|
|
<input type="text" id="f-filename" placeholder="wird automatisch aus dem Titel erzeugt">
|
||
|
|
</div>
|
||
|
|
<div class="full">
|
||
|
|
<label>INHALT (Markdown)</label>
|
||
|
|
<textarea id="f-body" placeholder="### STATUS: ..."></textarea>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="btn-row">
|
||
|
|
<button type="submit" class="primary" id="submit-btn">[ SPEICHERN ]</button>
|
||
|
|
<button type="button" class="reset-btn" onclick="resetForm()">[ ABBRECHEN ]</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="box">
|
||
|
|
<div class="table-header">
|
||
|
|
<h2>PROJEKTE</h2>
|
||
|
|
<span class="count" id="count-label">${posts.length} EINTRÄGE</span>
|
||
|
|
</div>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr><th>TITEL</th><th>DATUM</th><th>STATUS</th><th>DATEI</th><th></th></tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="post-table">${rows}</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="toast" id="toast"></div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
let POSTS = ${json};
|
||
|
|
|
||
|
|
function toast(msg, err) {
|
||
|
|
const t = document.getElementById('toast');
|
||
|
|
t.textContent = msg;
|
||
|
|
t.className = 'toast' + (err ? ' err' : '') + ' show';
|
||
|
|
setTimeout(() => t.classList.remove('show'), 2000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findPost(filename) { return POSTS.find(p => p.filename === filename); }
|
||
|
|
|
||
|
|
function editRow(filename) {
|
||
|
|
const p = findPost(filename);
|
||
|
|
if (!p) return;
|
||
|
|
document.getElementById('edit-filename').value = p.filename;
|
||
|
|
document.getElementById('f-title').value = p.title;
|
||
|
|
document.getElementById('f-date').value = p.date;
|
||
|
|
document.getElementById('f-status-class').value = p.status_class;
|
||
|
|
document.getElementById('f-status-label').value = p.status_label;
|
||
|
|
document.getElementById('f-thumb').value = p.thumb;
|
||
|
|
document.getElementById('f-filename').value = p.filename;
|
||
|
|
document.getElementById('f-filename').disabled = true;
|
||
|
|
document.getElementById('f-body').value = p.body;
|
||
|
|
document.getElementById('form-title').textContent = '✏ PROJEKT BEARBEITEN: ' + p.filename;
|
||
|
|
document.getElementById('submit-btn').textContent = '[ AKTUALISIEREN ]';
|
||
|
|
document.getElementById('post-form').scrollIntoView({ behavior: 'smooth' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetForm() {
|
||
|
|
document.getElementById('post-form').reset();
|
||
|
|
document.getElementById('edit-filename').value = '';
|
||
|
|
document.getElementById('f-filename').disabled = false;
|
||
|
|
document.getElementById('form-title').textContent = '+ NEUES PROJEKT';
|
||
|
|
document.getElementById('submit-btn').textContent = '[ SPEICHERN ]';
|
||
|
|
}
|
||
|
|
|
||
|
|
function delRow(filename) {
|
||
|
|
if (!confirm('Projekt "' + filename + '" wirklich löschen?')) return;
|
||
|
|
fetch('/delete', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ filename })
|
||
|
|
}).then(r => r.json()).then(r => {
|
||
|
|
if (r.ok) { toast('✓ GELÖSCHT'); setTimeout(() => location.reload(), 400); }
|
||
|
|
else toast('Fehler: ' + r.error, true);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('post-form').addEventListener('submit', e => {
|
||
|
|
e.preventDefault();
|
||
|
|
const editFilename = document.getElementById('edit-filename').value;
|
||
|
|
const entry = {
|
||
|
|
filename: editFilename || document.getElementById('f-filename').value.trim(),
|
||
|
|
title: document.getElementById('f-title').value.trim(),
|
||
|
|
date: document.getElementById('f-date').value,
|
||
|
|
status_class: document.getElementById('f-status-class').value,
|
||
|
|
status_label: document.getElementById('f-status-label').value.trim(),
|
||
|
|
thumb: document.getElementById('f-thumb').value.trim(),
|
||
|
|
body: document.getElementById('f-body').value,
|
||
|
|
};
|
||
|
|
fetch('/save', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ isNew: !editFilename, entry })
|
||
|
|
}).then(r => r.json()).then(r => {
|
||
|
|
if (r.ok) { toast('✓ GESPEICHERT'); setTimeout(() => location.reload(), 400); }
|
||
|
|
else toast('Fehler: ' + r.error, true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── SERVER ───────────────────────────────────────────────────────
|
||
|
|
const server = http.createServer((req, res) => {
|
||
|
|
const json = (status, obj) => {
|
||
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify(obj));
|
||
|
|
};
|
||
|
|
const body = (cb) => {
|
||
|
|
let b = '';
|
||
|
|
req.on('data', c => b += c);
|
||
|
|
req.on('end', () => cb(b));
|
||
|
|
};
|
||
|
|
|
||
|
|
if (req.method === 'GET' && req.url === '/') {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
|
|
res.end(html(readAllPosts()));
|
||
|
|
|
||
|
|
} else if (req.method === 'POST' && req.url === '/save') {
|
||
|
|
body(b => {
|
||
|
|
try {
|
||
|
|
const { isNew, entry } = JSON.parse(b);
|
||
|
|
if (!entry.title || !entry.status_label) throw new Error('Titel und Status-Label sind Pflicht');
|
||
|
|
|
||
|
|
let filename = entry.filename;
|
||
|
|
if (isNew) {
|
||
|
|
filename = uniqueSlug(slugify(filename || entry.title)) + '.md';
|
||
|
|
} else if (!filename.endsWith('.md')) {
|
||
|
|
filename += '.md';
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(POSTS_DIR, filename), serializePost({ ...entry, filename }), 'utf8');
|
||
|
|
json(200, { ok: true, filename });
|
||
|
|
} catch (e) {
|
||
|
|
json(400, { ok: false, error: e.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
} else if (req.method === 'POST' && req.url === '/delete') {
|
||
|
|
body(b => {
|
||
|
|
try {
|
||
|
|
const { filename } = JSON.parse(b);
|
||
|
|
const target = path.join(POSTS_DIR, filename);
|
||
|
|
if (!target.startsWith(POSTS_DIR) || !filename.endsWith('.md')) {
|
||
|
|
throw new Error('Ungültiger Dateiname');
|
||
|
|
}
|
||
|
|
fs.unlinkSync(target);
|
||
|
|
json(200, { ok: true });
|
||
|
|
} catch (e) {
|
||
|
|
json(400, { ok: false, error: e.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
} else {
|
||
|
|
res.writeHead(404); res.end('Not found');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
server.listen(PORT, '127.0.0.1', () => {
|
||
|
|
console.log(`\n PROJEKT-ARCHIV ADMIN → http://localhost:${PORT}\n`);
|
||
|
|
});
|