Compare commits
5 Commits
9163f5c90d
...
0f574bf8a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f574bf8a7 | |||
| 9c9b7e03bd | |||
| d9ba2f7bbe | |||
| 37fdc9019c | |||
| 1fb4556ac1 |
@@ -11,6 +11,8 @@ hugo_stats.json
|
||||
|
||||
# Node (CMS)
|
||||
node_modules/
|
||||
# Admin-SPA Build-Output (wird im Container gebaut)
|
||||
cms/admin/dist/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "{{ replace .File.ContentBaseName `-` ` ` | title }}"
|
||||
group: "Allgemein"
|
||||
summary: ""
|
||||
toc: false
|
||||
---
|
||||
+66
-2
@@ -704,6 +704,67 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
||||
.dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||
.dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; }
|
||||
|
||||
/* ── Dialog: Lade-Skelett, Links im Text, Composer-Hinweis ── */
|
||||
.dialog-skel { display: flex; flex-direction: column; gap: 1.1em; padding: 0.7em 0; }
|
||||
.dialog-skel-line {
|
||||
height: 1.05em; width: 100%; border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--color-border) 25%, var(--color-bg-secondary) 37%, var(--color-border) 63%);
|
||||
background-size: 400% 100%; animation: dialog-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.dialog-skel-line:nth-child(3n) { width: 68%; }
|
||||
.dialog-skel-line:nth-child(3n+1) { width: 92%; }
|
||||
@keyframes dialog-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dialog-skel-line { animation: none; } }
|
||||
.dialog-body .dialog-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
|
||||
.dialog-hint { font-size: var(--font-size-small); color: var(--color-text-muted); align-self: center; opacity: 0.7; }
|
||||
.dialog-spacer { flex: 1; }
|
||||
|
||||
/* ── Wiki: zweispaltig (Seitenleiste + Inhalt), schlicht ── */
|
||||
.wiki { display: grid; grid-template-columns: 220px 1fr; gap: 2.6em; align-items: start; width: 100%; }
|
||||
@media (max-width: 760px) { .wiki { grid-template-columns: 1fr; gap: 1.4em; } .wiki-side { position: static; } }
|
||||
.wiki-side { position: sticky; top: 1em; align-self: start; }
|
||||
.wiki-nav { display: flex; flex-direction: column; gap: 0.35em; font-size: var(--font-size-small); }
|
||||
.wiki-nav-home { text-decoration: none; color: var(--color-text-primary); font-weight: 600; }
|
||||
.wiki-nav-home:hover { color: var(--accent); }
|
||||
.wiki-nav-home.is-current { color: var(--accent); }
|
||||
.wiki-filter { width: 100%; padding: 0.4em 0.7em; margin: 0.3em 0 0.4em; font: inherit; font-size: var(--font-size-small);
|
||||
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg-primary); }
|
||||
.wiki-filter:focus { outline: none; border-color: var(--accent); }
|
||||
.wiki-nav-title { text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.7rem; color: var(--color-text-muted); margin: 0.8em 0 0.25em; }
|
||||
.wiki-nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.1em; }
|
||||
.wiki-nav li a { display: block; text-decoration: none; color: var(--color-text-muted);
|
||||
padding: 0.18em 0 0.18em 0.7em; border-left: 2px solid transparent; }
|
||||
.wiki-nav li a:hover { color: var(--accent); }
|
||||
.wiki-nav li a.is-current { color: var(--color-text-primary); font-weight: 600; border-left-color: var(--accent); }
|
||||
|
||||
.wiki-page { min-width: 0; }
|
||||
.wiki-rubric { text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.72rem; color: var(--color-text-muted); margin: 0 0 0.2em; }
|
||||
.wiki-head h1 { margin: 0 0 0.2em; }
|
||||
.wiki-index { margin-top: var(--spacing-md); }
|
||||
.wiki-group { margin-bottom: var(--spacing-md); }
|
||||
.wiki-group h2 { font-family: var(--font-family-serif); font-size: 1.1rem; margin: 0 0 0.4em; }
|
||||
.wiki-group ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.wiki-group li a { font-weight: 600; text-decoration: none; }
|
||||
.wiki-group li a:hover { color: var(--accent); }
|
||||
.wiki-foot { margin-top: var(--spacing-lg); padding-top: var(--spacing-sm); border-top: 1px solid var(--color-border);
|
||||
display: flex; gap: 1.2em; flex-wrap: wrap; font-size: var(--font-size-small); color: var(--color-text-muted); }
|
||||
.wiki-foot a { color: var(--color-text-muted); text-decoration: none; }
|
||||
.wiki-foot a:hover { color: var(--accent); }
|
||||
|
||||
/* ── Software-Landing: Werkzeuge getrennt von Texten ── */
|
||||
.software-h { font-family: var(--font-family-serif); margin: var(--spacing-md) 0 var(--spacing-sm); }
|
||||
.software-tools { margin-bottom: var(--spacing-lg); }
|
||||
.tool-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.8em; }
|
||||
.tool-item { display: flex; align-items: flex-start; gap: 0.5em; background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border); border-left: 4px solid var(--section-color, var(--accent)); border-radius: 12px; padding: 0.9em 1em; }
|
||||
.tool-item:hover { border-color: var(--section-color, var(--accent)); }
|
||||
.tool-main { flex: 1; text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.2em; min-width: 0; }
|
||||
.tool-name { font-family: var(--font-family-serif); font-weight: 600; font-size: 1.05rem; }
|
||||
.tool-item:hover .tool-name { color: var(--accent); }
|
||||
.tool-sum { font-size: var(--font-size-small); }
|
||||
.tool-ext { flex: none; color: var(--color-text-muted); text-decoration: none; font-size: 1.15em; line-height: 1; }
|
||||
.tool-ext:hover { color: var(--accent); }
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Journal entries — three Republik-style layouts (set in front matter
|
||||
via `layout: image|icon|text`). Every entry is a full-bleed coloured
|
||||
@@ -1557,14 +1618,17 @@ img:hover { filter: grayscale(0%); }
|
||||
.cite-fmt.is-active { color: var(--accent); font-weight: 600; }
|
||||
.cite-status { color: var(--color-text-muted); }
|
||||
|
||||
/* Aktionsreihe: Dialog links, Tags ganz rechts; darüber eine Trennlinie. */
|
||||
/* Aktionsreihe: Dialog links, Tags ganz rechts; darüber eine Trennlinie.
|
||||
margin-top = spacing-sm, damit der Abstand „zitieren → Trennlinie" gleich
|
||||
gross ist wie „obere Trennlinie → Quellen" (footnotes padding-top) — der
|
||||
Quellen-Block sitzt so symmetrisch zwischen den beiden Linien. */
|
||||
.article-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8em 1em;
|
||||
margin-top: var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
+110
-30
@@ -22,13 +22,13 @@ const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transpa
|
||||
|
||||
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||
const KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||
const KIND_LABEL = { beitrag: 'Beiträge', wiki: 'Wiki', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||
|
||||
const EMPTY = {
|
||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
||||
cover_image: '', external: '', authors: '', toc: false, draft: true, body: '',
|
||||
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
@@ -89,7 +89,7 @@ function Dashboard({ email }) {
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||
const groups = { beitrag: [], seite: [], rubrik: [] };
|
||||
const groups = { beitrag: [], wiki: [], seite: [], rubrik: [] };
|
||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
||||
|
||||
return (
|
||||
@@ -98,6 +98,7 @@ function Dashboard({ email }) {
|
||||
<span className="logo">OPENBUREAU</span>
|
||||
<span className="logo-sub">Redaktion</span>
|
||||
<nav className="nav">
|
||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||
@@ -110,7 +111,9 @@ function Dashboard({ email }) {
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
{view === 'profile' ? (
|
||||
{view === 'overview' ? (
|
||||
<Overview onMsg={setMsg} go={setView} />
|
||||
) : view === 'profile' ? (
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
@@ -123,7 +126,7 @@ function Dashboard({ email }) {
|
||||
<aside>
|
||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||
{['beitrag', 'wiki', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||
<div className="group" key={kind}>
|
||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||
<ul className="list">
|
||||
@@ -166,6 +169,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
const dragging = useRef(false);
|
||||
const coverIn = useRef(null);
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||
const isWiki = f.type === 'wiki' || (f.path || '').startsWith('wiki/');
|
||||
|
||||
async function pickCover(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
@@ -194,7 +198,9 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
if (!data.isNew) return data.path;
|
||||
const slug = (data.slug || '').trim();
|
||||
if (!slug) return '';
|
||||
return data.type === 'beitrag' ? `library/${data.section}/${slug}.md` : `${slug}.md`;
|
||||
if (data.type === 'beitrag') return `library/${data.section}/${slug}.md`;
|
||||
if (data.type === 'wiki') return `wiki/${slug}.md`;
|
||||
return `${slug}.md`;
|
||||
}
|
||||
|
||||
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
||||
@@ -250,6 +256,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
<label className="sm">Typ
|
||||
<select value={f.type} onChange={set('type')}>
|
||||
<option value="beitrag">Beitrag</option>
|
||||
<option value="wiki">Wiki-Seite</option>
|
||||
<option value="seite">Seite</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -265,6 +272,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
||||
|
||||
<div className="meta">
|
||||
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
||||
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
||||
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||
<label className="sm">Farbe
|
||||
@@ -398,12 +406,55 @@ function Profile({ onMsg }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
|
||||
function Overview({ onMsg, go }) {
|
||||
const [s, setS] = useState(null);
|
||||
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||
if (!s) return <div className="empty">…</div>;
|
||||
const Card = ({ label, value, hint, to }) => (
|
||||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||
<span className="stat-value">{value}</span>
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className="stat-hint">{hint || ' '}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="overview">
|
||||
<h2>Übersicht</h2>
|
||||
<div className="stat-grid">
|
||||
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
||||
<Card label="Wiki-Seiten" value={s.content.wiki} to="content" />
|
||||
<Card label="Seiten" value={s.content.seiten} />
|
||||
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
||||
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
||||
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
||||
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<h3>Schnellzugriff</h3>
|
||||
<div className="quick">
|
||||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||
<button onClick={() => go('forums')}>Foren verwalten</button>
|
||||
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||||
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||||
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog ↗</a>
|
||||
<a className="quick-link" href="/wiki/" target="_blank" rel="noreferrer">Wiki ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||
function Users({ onMsg, currentEmail }) {
|
||||
const [list, setList] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('user');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [pwFor, setPwFor] = useState(null);
|
||||
const [newPw, setNewPw] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try { setList(await api.listUsers()); }
|
||||
@@ -413,59 +464,85 @@ function Users({ onMsg, currentEmail }) {
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault(); setBusy(true);
|
||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
try {
|
||||
await api.createUser(email, password, role);
|
||||
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
|
||||
setEmail(''); setPassword(''); setRole('user'); refresh();
|
||||
} catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(u) {
|
||||
if (!confirm(`${u.email} löschen?`)) return;
|
||||
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||||
try { await api.deleteUser(u.id); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function reset(u) {
|
||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
||||
if (!pw) return;
|
||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||
async function savePw(u) {
|
||||
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function changeRole(u, role) {
|
||||
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
||||
async function changeRole(u, r) {
|
||||
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
|
||||
const RoleSelect = ({ u }) => (
|
||||
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
);
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card">
|
||||
<h2>Autor:innen & Rollen</h2>
|
||||
<div className="profile-card wide">
|
||||
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||||
<form className="userform" onSubmit={create}>
|
||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
<button className="primary" disabled={busy}>Anlegen</button>
|
||||
</form>
|
||||
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||||
<ul className="userlist">
|
||||
{list.map((u) => (
|
||||
{filtered.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="t">{u.email}</span>
|
||||
{u.fixedAdmin
|
||||
? <span className="status live">Admin (.env)</span>
|
||||
: <select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||
<option value="user">User</option>
|
||||
<option value="editor">Redakteur</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>}
|
||||
<button onClick={() => reset(u)}>Passwort</button>
|
||||
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="t ucol">
|
||||
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||||
<span className="umeta">
|
||||
angelegt {fmtDate(u.created_at)}
|
||||
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||||
</span>
|
||||
</span>
|
||||
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
|
||||
{pwFor === u.id ? (
|
||||
<span className="pwinline">
|
||||
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
|
||||
<button onClick={() => savePw(u)}>OK</button>
|
||||
<button onClick={() => { setPwFor(null); setNewPw(''); }}>✕</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
|
||||
)}
|
||||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail"><b>User</b> schreiben nur im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
|
||||
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
|
||||
|
||||
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||
function Forums({ onMsg }) {
|
||||
@@ -603,15 +680,17 @@ function slugify(s) {
|
||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||
function fromRead(r) {
|
||||
const fm = r.frontmatter || {};
|
||||
const p = r.path || '';
|
||||
const type = p.startsWith('library/') ? 'beitrag' : p.startsWith('wiki/') ? 'wiki' : 'seite';
|
||||
return {
|
||||
isNew: false, path: r.path, type: 'beitrag', section: '', slug: '',
|
||||
isNew: false, path: r.path, type, section: '', slug: '',
|
||||
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
||||
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
||||
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
||||
summary: fm.summary || '', description: fm.description || '',
|
||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
||||
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||
};
|
||||
}
|
||||
function buildFrontmatter(f) {
|
||||
@@ -628,6 +707,7 @@ function buildFrontmatter(f) {
|
||||
if (f.color) fm.color = f.color;
|
||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
if (authors.length) fm.authors = authors;
|
||||
if (f.group) fm.group = f.group;
|
||||
if (f.toc) fm.toc = true;
|
||||
if (f.draft) fm.draft = true;
|
||||
return fm;
|
||||
|
||||
@@ -44,8 +44,9 @@ export const api = {
|
||||
getProfile: () => call('/profile'),
|
||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||
getMe: () => call('/me'),
|
||||
stats: () => call('/stats'),
|
||||
listUsers: () => call('/users'),
|
||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -184,6 +184,37 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
||||
.mod-actions a { color: var(--muted); }
|
||||
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||
|
||||
/* ── Übersicht / Dashboard ── */
|
||||
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
|
||||
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
|
||||
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
|
||||
.stat-card:disabled { opacity: 1; cursor: default; }
|
||||
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
|
||||
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
|
||||
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
|
||||
.overview-actions { margin-top: 30px; }
|
||||
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
|
||||
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
|
||||
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
|
||||
|
||||
/* ── Nutzerliste (aufgewertet) ── */
|
||||
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
|
||||
.userfilter { margin: 4px 0 2px; height: 34px; }
|
||||
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
|
||||
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
|
||||
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
|
||||
.umeta { font-size: 11.5px; color: var(--muted); }
|
||||
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
|
||||
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
|
||||
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
|
||||
.pwinline input { width: 150px; height: 30px; }
|
||||
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||
.toast.ok { background: var(--ok); }
|
||||
|
||||
@@ -37,6 +37,9 @@ function classify(rel) {
|
||||
if (parts[0] === 'library' && parts.length === 3) {
|
||||
return { kind: 'beitrag', section: parts[1] };
|
||||
}
|
||||
if (parts[0] === 'wiki') {
|
||||
return { kind: 'wiki', section: 'wiki' };
|
||||
}
|
||||
return { kind: 'seite', section: null };
|
||||
}
|
||||
|
||||
@@ -82,8 +85,8 @@ export async function listEntries() {
|
||||
url: urlFor(rel),
|
||||
});
|
||||
}
|
||||
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
|
||||
const order = { beitrag: 0, seite: 1, rubrik: 2 };
|
||||
// Beiträge zuerst, dann Wiki, Seiten, Rubriken; je nach Datum/Titel.
|
||||
const order = { beitrag: 0, wiki: 1, seite: 2, rubrik: 3 };
|
||||
items.sort((a, b) =>
|
||||
(order[a.kind] - order[b.kind]) ||
|
||||
(b.date || '').localeCompare(a.date || '') ||
|
||||
|
||||
@@ -11,6 +11,7 @@ import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import stats from './routes/stats.js';
|
||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import history from './routes/history.js';
|
||||
import {
|
||||
@@ -91,6 +92,7 @@ app.route('/api/publish', publish);
|
||||
app.route('/api/upload', upload);
|
||||
app.route('/api/profile', profile);
|
||||
app.route('/api/users', users);
|
||||
app.route('/api/stats', stats);
|
||||
|
||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { listEntries } from '../files.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
|
||||
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||
const stats = new Hono();
|
||||
stats.use('*', requireAdmin);
|
||||
|
||||
stats.get('/', async (c) => {
|
||||
// Inhalte aus dem Dateisystem zählen.
|
||||
const content = { beitraege: 0, entwuerfe: 0, wiki: 0, seiten: 0, rubriken: 0 };
|
||||
try {
|
||||
for (const e of await listEntries()) {
|
||||
if (e.kind === 'beitrag') { content.beitraege++; if (e.draft) content.entwuerfe++; }
|
||||
else if (e.kind === 'wiki') content.wiki++;
|
||||
else if (e.kind === 'rubrik') content.rubriken++;
|
||||
else content.seiten++;
|
||||
}
|
||||
} catch { /* Filesystem nicht lesbar → 0 */ }
|
||||
|
||||
// Nutzer nach Rolle.
|
||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
|
||||
// Dialog-Zähler (effizient: head + count, keine Zeilen laden).
|
||||
const count = async (table, filter) => {
|
||||
try {
|
||||
let q = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||
if (filter) q = filter(q);
|
||||
const { count: n } = await q;
|
||||
return n || 0;
|
||||
} catch { return 0; }
|
||||
};
|
||||
const [forums, threads, comments] = await Promise.all([
|
||||
count('forums'),
|
||||
count('threads', (q) => q.eq('deleted', false)),
|
||||
count('comments', (q) => q.eq('deleted', false)),
|
||||
]);
|
||||
|
||||
return c.json({ content, users, dialog: { forums, threads, comments } });
|
||||
});
|
||||
|
||||
export default stats;
|
||||
@@ -18,6 +18,7 @@ users.get('/', async (c) => {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
created_at: u.created_at,
|
||||
last_sign_in_at: u.last_sign_in_at || null,
|
||||
role,
|
||||
isAdmin: role === 'admin',
|
||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||
@@ -28,9 +29,12 @@ users.get('/', async (c) => {
|
||||
});
|
||||
|
||||
users.post('/', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
const { email, password, role } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true });
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
const payload = { email, password, email_confirm: true };
|
||||
if (role && role !== 'user') payload.app_metadata = { role };
|
||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true, id: data.user.id });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "Wiki"
|
||||
summary: "Das Werkstattwissen des Büros — verlinkte Notizen, die mit der Praxis wachsen."
|
||||
---
|
||||
|
||||
Die [Bibliothek](/library/) sammelt fertige Texte; das Wiki sammelt das Dazwischen — Begriffe, Handgriffe, Konventionen, die man im Alltag nachschlägt. Es ist bewusst unfertig: Eine Seite darf kurz sein, Lücken haben, sich später widersprechen — solange sie wächst, statt in einer Schublade zu verschwinden.
|
||||
|
||||
Links findest du alle Seiten nach Bereich gruppiert; das Feld oben filtert sie. Wer einen Eintrag ergänzen oder neu anlegen will, tut das im Redaktions-Editor oder direkt im Repository.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "Dateiablage & Benennung"
|
||||
group: "Konventionen"
|
||||
summary: "Wie Projektdateien heissen, damit man sie in fünf Jahren noch findet."
|
||||
toc: true
|
||||
---
|
||||
|
||||
Eine Konvention ist nur dann eine, wenn sich alle daran halten. Dies ist ein Vorschlag, kein Gesetz — verbessern erwünscht.
|
||||
|
||||
## Projektordner
|
||||
|
||||
Jedes Projekt liegt unter `Projekte/JJJJ_Nummer_Kurzname/`, z. B. `2026_014_Mehrfamilienhaus-Seeblick/`. Das Jahr vorne sortiert chronologisch, die Nummer ist eindeutig, der Kurzname macht es lesbar.
|
||||
|
||||
## Dateinamen
|
||||
|
||||
`JJMMTT_Projekt_Inhalt_vNN` — Datum zuerst (sortiert sich selbst), dann was es ist, dann die Version:
|
||||
|
||||
- `260604_Seeblick_Grundriss-EG_v03.pdf`
|
||||
- `260604_Seeblick_Kostenschaetzung_v01.xlsx`
|
||||
|
||||
Keine Umlaute, keine Leerzeichen, keine Sonderzeichen — Bindestrich trennt Wörter, Unterstrich trennt Felder.
|
||||
|
||||
## Versionen
|
||||
|
||||
`vNN` zählt hoch, nichts wird überschrieben. Die jeweils gültige Fassung bekommt keinen Sonderstatus im Namen — das erledigt das Datum. Wer mit Git arbeitet, lässt die Versionsnummer weg und vertraut der Historie.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Typus"
|
||||
group: "Begriffe"
|
||||
summary: "Ein Prinzip, das viele Werke begründet — nicht die Vorlage zum Kopieren."
|
||||
---
|
||||
|
||||
Der **Typus** ist nicht das fertige Vorbild, sondern das zugrunde liegende Prinzip einer Bauaufgabe: das, was eine Markthalle zur Markthalle macht, unabhängig von Ort, Material und Epoche. Vom *Modell* unterscheidet er sich darin, dass man ihn nicht kopiert, sondern gegen ihn entwirft.
|
||||
|
||||
Fürs Büro ist der Typus ein Werkzeug der Ökonomie: Wer den Typus einer Aufgabe kennt, beginnt nicht bei null, sondern variiert bewusst — und kann die eigenen Entscheidungen begründen.
|
||||
|
||||
Ausführlicher in der Bibliothek: [Typus und Modell](/library/theorie/muster-typologie-fussnoten/).
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Wie dieses Wiki funktioniert"
|
||||
group: "Werkstatt"
|
||||
summary: "Kleine Seiten, klare Titel, viele Verweise."
|
||||
toc: true
|
||||
---
|
||||
|
||||
Dieses Wiki ist kein Lexikon, das jemand fertigstellt, sondern ein gemeinsames Gedächtnis, das beim Arbeiten entsteht. Ein paar Konventionen halten es übersichtlich.
|
||||
|
||||
## Eine Seite, ein Begriff
|
||||
|
||||
Lieber viele kleine Seiten als wenige grosse. Eine Seite behandelt einen Begriff, einen Handgriff, eine Entscheidung. Passt etwas nicht mehr auf eine Bildschirmseite, wird es meist zwei Themen sein.
|
||||
|
||||
## Verweise
|
||||
|
||||
Seiten verweisen mit gewöhnlichen Markdown-Links aufeinander — `[Typus](/wiki/typus/)` — und gerne auch in die [Bibliothek](/library/), wenn ein Gedanke dort ausführlicher steht. Verlinken ist die eigentliche Arbeit: Eine Notiz, auf die nichts zeigt, findet niemand.
|
||||
|
||||
## Gruppen
|
||||
|
||||
Das Feld `group` im Frontmatter sortiert eine Seite in die Navigation links — z. B. `group: "Begriffe"`. Seiten ohne Gruppe landen unter „Allgemein". Mehr Struktur braucht es selten.
|
||||
|
||||
## Bearbeiten
|
||||
|
||||
Jede Seite hat unten einen **bearbeiten**-Link, der direkt ins Repository führt. Wer lieber im Redaktions-Editor arbeitet, legt eine Seite vom Typ *Wiki* an und füllt Titel, Gruppe und Text.
|
||||
@@ -69,6 +69,9 @@ menus:
|
||||
- name: LIBRARY
|
||||
pageRef: /library
|
||||
weight: 20
|
||||
- name: WIKI
|
||||
pageRef: /wiki
|
||||
weight: 25
|
||||
- name: MANIFEST
|
||||
pageRef: /manifest
|
||||
weight: 30
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- /* Wiki-Seitenleiste: alle Wiki-Seiten nach `group` gruppiert, mit Filter.
|
||||
Seiten ohne `group` landen unter „Allgemein“. */ -}}
|
||||
{{- $cur := .RelPermalink -}}
|
||||
{{- $pages := where site.RegularPages "Section" "wiki" -}}
|
||||
{{- $groups := dict -}}
|
||||
{{- range $pages -}}
|
||||
{{- $g := .Params.group | default "Allgemein" -}}
|
||||
{{- $existing := index $groups $g | default slice -}}
|
||||
{{- $groups = merge $groups (dict $g ($existing | append .)) -}}
|
||||
{{- end -}}
|
||||
<nav class="wiki-nav" aria-label="Wiki-Navigation">
|
||||
<a class="wiki-nav-home{{ if .IsSection }} is-current{{ end }}" href="/wiki/">Übersicht</a>
|
||||
<input class="wiki-filter" type="search" placeholder="filtern…" aria-label="Wiki filtern" autocomplete="off" />
|
||||
{{- range $g, $ps := $groups }}
|
||||
<div class="wiki-nav-group">
|
||||
<div class="wiki-nav-title">{{ $g }}</div>
|
||||
<ul>
|
||||
{{- range sort $ps "Title" }}
|
||||
<li><a href="{{ .RelPermalink }}"{{ if eq .RelPermalink $cur }} class="is-current" aria-current="page"{{ end }}>{{ .LinkTitle }}</a></li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{- end }}
|
||||
</nav>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__wikiFilter) return; window.__wikiFilter = 1;
|
||||
var inp = document.querySelector('.wiki-filter'); if (!inp) return;
|
||||
inp.addEventListener('input', function () {
|
||||
var q = inp.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.wiki-nav-group').forEach(function (g) {
|
||||
var any = false;
|
||||
g.querySelectorAll('li').forEach(function (li) {
|
||||
var hit = li.textContent.toLowerCase().indexOf(q) > -1;
|
||||
li.style.display = hit ? '' : 'none'; if (hit) any = true;
|
||||
});
|
||||
g.style.display = any ? '' : 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -36,6 +36,53 @@
|
||||
</article>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ else if eq .Path "/library/software" }}
|
||||
{{/* Software: kuratierte Landing — Werkzeuge (mit externem Link) getrennt
|
||||
von Texten & Anleitungen. */}}
|
||||
<header class="section-header" data-section="software">
|
||||
<p class="section-rubric">Library</p>
|
||||
<h1 class="section-title">{{ .Title }}</h1>
|
||||
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
|
||||
{{ $tools := where .RegularPages "Params.external" "!=" nil }}
|
||||
{{ $texts := where .RegularPages "Params.external" nil }}
|
||||
|
||||
{{ with $tools }}
|
||||
<section class="software-tools">
|
||||
<h2 class="software-h">Werkzeuge</h2>
|
||||
<ul class="tool-list">
|
||||
{{ range .ByWeight }}
|
||||
<li class="tool-item"{{ with .Params.color }} data-color="{{ . }}"{{ end }}>
|
||||
<a class="tool-main" href="{{ .RelPermalink }}">
|
||||
<span class="tool-name">{{ .LinkTitle }}</span>
|
||||
{{ with .Params.summary }}<span class="tool-sum text-muted">{{ . }}</span>{{ end }}
|
||||
</a>
|
||||
{{ with .Params.external }}<a class="tool-ext" href="{{ . }}" rel="noopener" aria-label="extern öffnen">↗</a>{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
<section class="software-texts">
|
||||
<h2 class="software-h">Texte & Anleitungen</h2>
|
||||
<div class="time-list" data-section="software">
|
||||
<ul>
|
||||
{{ range $texts.ByDate.Reverse }}
|
||||
<li class="list-item">
|
||||
<div class="list-title-row">
|
||||
<div class="list-title">
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}<div class="list-summary text-muted">{{ . }}</div>{{ end }}
|
||||
</div>
|
||||
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{{ else }}
|
||||
{{/* Library subsection: chronologisch */}}
|
||||
{{ $section := path.Base .RelPermalink }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{ define "main" }}
|
||||
<div class="wiki">
|
||||
<aside class="wiki-side">{{ partial "wiki-nav.html" . }}</aside>
|
||||
|
||||
<article class="wiki-page">
|
||||
<header class="wiki-head">
|
||||
<p class="wiki-rubric">Wiki</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="single-content">{{ .Content }}</div>
|
||||
|
||||
{{ $pages := where site.RegularPages "Section" "wiki" }}
|
||||
{{ $groups := dict }}
|
||||
{{ range $pages }}
|
||||
{{ $g := .Params.group | default "Allgemein" }}
|
||||
{{ $existing := index $groups $g | default slice }}
|
||||
{{ $groups = merge $groups (dict $g ($existing | append .)) }}
|
||||
{{ end }}
|
||||
|
||||
{{ if $pages }}
|
||||
<div class="wiki-index">
|
||||
{{ range $g, $ps := $groups }}
|
||||
<section class="wiki-group">
|
||||
<h2>{{ $g }}</h2>
|
||||
<ul>
|
||||
{{ range sort $ps "Title" }}
|
||||
<li>
|
||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
{{ with .Params.summary }}<span class="text-muted"> — {{ . }}</span>{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-muted"><em>Noch keine Einträge — der erste entsteht im Redaktions-Editor.</em></p>
|
||||
{{ end }}
|
||||
</article>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{ define "main" }}
|
||||
<div class="wiki">
|
||||
<aside class="wiki-side">{{ partial "wiki-nav.html" . }}</aside>
|
||||
|
||||
<article class="wiki-page">
|
||||
<header class="wiki-head">
|
||||
<p class="wiki-rubric">Wiki{{ with .Params.group }} · {{ . }}{{ end }}</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ with .Params.summary }}<p class="single-summary">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
|
||||
{{ $hasToC := .Params.toc | default false }}
|
||||
{{ $headers := findRE "<h[2-6]" .Content }}
|
||||
{{ if and $hasToC (ge (len $headers) 2) }}
|
||||
<nav class="toc">
|
||||
<strong>Inhalt</strong>
|
||||
<div class="toc-content">{{ .TableOfContents }}</div>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
||||
<div class="single-content wiki-content">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
|
||||
<footer class="wiki-foot">
|
||||
{{ if .Lastmod }}<span>Zuletzt bearbeitet am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
||||
{{ with .File }}
|
||||
<a href="{{ site.Params.repoURL }}/_edit/branch/main/content/{{ .Path }}" rel="nofollow">bearbeiten ↗</a>
|
||||
{{ end }}
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
{{ end }}
|
||||
+54
-4
@@ -36,6 +36,45 @@
|
||||
const api = (p, opt) => fetch(p, opt).then(async (r) => ({ ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }));
|
||||
const authHdr = () => ({ Authorization: 'Bearer ' + token });
|
||||
|
||||
// ── UX-Helfer ─────────────────────────────────────────────────────────────
|
||||
// Deterministische, dezente Avatar-Farbe aus dem Namen (wenn kein Bild).
|
||||
function hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||
function paintAvatar(av, name, avatarUrl) {
|
||||
if (avatarUrl) { av.style.backgroundImage = 'url(' + avatarUrl + ')'; av.textContent = ''; return; }
|
||||
const hue = hashHue(name || '?');
|
||||
av.style.background = 'hsl(' + hue + ' 36% 82%)';
|
||||
av.style.color = 'hsl(' + hue + ' 30% 28%)';
|
||||
av.textContent = (name || '?').trim().slice(0, 1).toUpperCase();
|
||||
}
|
||||
// URLs im Text klickbar machen — sicher: nur Text- + Anchor-Knoten, kein innerHTML.
|
||||
function linkify(container, text) {
|
||||
const re = /(https?:\/\/[^\s<]+)/g; let last = 0, m;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) container.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||
const a = el('a', 'dialog-link', m[0].replace(/[.,;:)]+$/, ''));
|
||||
a.href = a.textContent; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
container.appendChild(a);
|
||||
last = m.index + a.textContent.length;
|
||||
}
|
||||
if (last < text.length) container.appendChild(document.createTextNode(text.slice(last)));
|
||||
}
|
||||
// Dezente Lade-Platzhalter (schimmernd).
|
||||
function skeleton(container, n) {
|
||||
container.innerHTML = '';
|
||||
const w = el('div', 'dialog-skel');
|
||||
for (let i = 0; i < (n || 3); i++) w.appendChild(el('div', 'dialog-skel-line'));
|
||||
container.appendChild(w);
|
||||
}
|
||||
// ⌘/Ctrl + Enter sendet ab.
|
||||
function sendOnCmdEnter(ta, fn) {
|
||||
ta.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); fn(); } });
|
||||
}
|
||||
// Textarea wächst mit dem Inhalt mit (bis zu einer Grenze).
|
||||
function autoGrow(ta, max) {
|
||||
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, max || 320) + 'px'; };
|
||||
ta.addEventListener('input', fit); requestAnimationFrame(fit);
|
||||
}
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────────
|
||||
async function doLogin(email, password, after) {
|
||||
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
|
||||
@@ -84,7 +123,11 @@
|
||||
right.appendChild(el('h2', 'dialog-title', 'Foren'));
|
||||
grid.append(left, right); root.appendChild(grid);
|
||||
|
||||
const recentSkel = el('div'); left.appendChild(recentSkel); skeleton(recentSkel, 5);
|
||||
const forumSkel = el('div'); right.appendChild(forumSkel); skeleton(forumSkel, 4);
|
||||
|
||||
api('/api/recent?limit=15').then((r) => {
|
||||
recentSkel.remove();
|
||||
const rows = r.body || [];
|
||||
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
|
||||
const list = el('div', 'dialog-recent-list');
|
||||
@@ -101,6 +144,7 @@
|
||||
});
|
||||
|
||||
api('/api/forums').then((r) => {
|
||||
forumSkel.remove();
|
||||
const rows = r.body || [];
|
||||
const list = el('div', 'dialog-forum-list');
|
||||
rows.forEach((f) => {
|
||||
@@ -121,7 +165,9 @@
|
||||
function renderForum(slug) {
|
||||
root.innerHTML = '';
|
||||
if (ctxEl) { ctxEl.innerHTML = ''; const c = el('nav', 'dialog-crumb'); const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; c.appendChild(b); ctxEl.appendChild(c); }
|
||||
const loadHost = el('div'); skeleton(loadHost, 4); root.appendChild(loadHost);
|
||||
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
|
||||
loadHost.remove();
|
||||
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
|
||||
const { forum, threads } = r.body;
|
||||
const head = el('div', 'dialog-forum-head');
|
||||
@@ -176,6 +222,8 @@
|
||||
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
|
||||
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
|
||||
};
|
||||
autoGrow(ta); sendOnCmdEnter(ta, () => send.click());
|
||||
ti.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ta.focus(); } });
|
||||
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
|
||||
}
|
||||
paint();
|
||||
@@ -189,6 +237,7 @@
|
||||
const list = el('div', 'dialog-list');
|
||||
const composer = el('div', 'dialog-composer');
|
||||
root.append(title, modbar, list, composer);
|
||||
skeleton(list, 3);
|
||||
let replyTo = null, textarea = null, locked = false;
|
||||
|
||||
// Kontext: Rücklink + Titel.
|
||||
@@ -249,8 +298,7 @@
|
||||
const post = el('article', 'dialog-post');
|
||||
const head = el('header', 'dialog-post-head');
|
||||
const av = el('span', 'dialog-avatar');
|
||||
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')';
|
||||
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
|
||||
paintAvatar(av, c.author_name, c.author_avatar);
|
||||
const ident = el('div', 'dialog-ident');
|
||||
const nameline = el('div', 'dialog-nameline');
|
||||
nameline.appendChild(el('span', 'dialog-name', c.author_name || 'Unbekannt'));
|
||||
@@ -258,7 +306,7 @@
|
||||
if (c.parent_id && names[c.parent_id]) nameline.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
|
||||
ident.append(nameline, el('time', 'dialog-time', fmtFull(c.created_at)));
|
||||
head.append(av, ident); post.appendChild(head);
|
||||
post.appendChild(el('div', 'dialog-body', c.body));
|
||||
const bodyEl = el('div', 'dialog-body'); linkify(bodyEl, c.body || ''); post.appendChild(bodyEl);
|
||||
if (token && !c.deleted) {
|
||||
const actions = el('div', 'dialog-actions');
|
||||
if (!locked) { const rep = el('button', null, 'Antworten'); rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; actions.appendChild(rep); }
|
||||
@@ -285,10 +333,12 @@
|
||||
composer.appendChild(r);
|
||||
}
|
||||
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
|
||||
autoGrow(textarea); sendOnCmdEnter(textarea, submit);
|
||||
const row = el('div', 'dialog-row');
|
||||
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
|
||||
const hint = el('span', 'dialog-hint', '⌘ + ↵');
|
||||
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
|
||||
row.append(send, out); composer.append(textarea, row);
|
||||
row.append(send, hint, el('span', 'dialog-spacer'), out); composer.append(textarea, row);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
|
||||
Reference in New Issue
Block a user