kilabor/admin-projects.js

355 lines
14 KiB
JavaScript
Raw Normal View History

2026-09-03 22:33:04 +00:00
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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`);
});