- FTS5-Volltextindex mit Trigger-Sync und einmaligem Backfill bestehender Devlogs - /api/devlogs?q= — Präfix-Suche, injection-sicher (Phrasen-Quoting) - Suchfeld mit Entprellung und Treffer-Zähler auf der Devlog-Seite - /feed.xml — RSS 2.0 der letzten 20 Devlogs (escaped, deutsche Titel) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.9 KiB
React
186 lines
7.9 KiB
React
import { useCallback, useEffect, useState } from 'react';
|
|
import { apiGet, apiDelete } from '../api.js';
|
|
import { Markdown } from '../markdown.jsx';
|
|
|
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
|
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
|
|
});
|
|
const monthFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
|
|
|
/** Relative Angabe wie „vor 3 Tagen" für die Karten-Ecke */
|
|
function relative(iso) {
|
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
|
if (days <= 0) return 'heute';
|
|
if (days === 1) return 'gestern';
|
|
if (days < 30) return `vor ${days} Tagen`;
|
|
const months = Math.floor(days / 30);
|
|
return months === 1 ? 'vor 1 Monat' : `vor ${months} Monaten`;
|
|
}
|
|
|
|
/**
|
|
* Devlog-Inhalt in Karten-Teile zerlegen:
|
|
* - Embed-Titel („Devlog — Projekt") → Projekt-Chip statt Fließtext
|
|
* - Abschließende Kursiv-Zeile („*X Commits heute …*") → abgesetzte Fußzeile
|
|
*/
|
|
function splitContent(raw) {
|
|
const blocks = raw.split(/\n{2,}/);
|
|
let project = null;
|
|
let footer = null;
|
|
|
|
const title = blocks[0]?.match(/^Devlog\s*[—–-]\s*(.+)$/i);
|
|
if (title) {
|
|
project = title[1].trim();
|
|
blocks.shift();
|
|
}
|
|
const last = blocks[blocks.length - 1];
|
|
if (blocks.length > 1 && /^\*[^*]+\*$/s.test(last?.trim() ?? '')) {
|
|
footer = last.trim().slice(1, -1);
|
|
blocks.pop();
|
|
}
|
|
return { project, footer, body: blocks.join('\n\n') };
|
|
}
|
|
|
|
export default function Devlogs({ me }) {
|
|
const [data, setData] = useState(null);
|
|
const [page, setPage] = useState(1);
|
|
const [error, setError] = useState(false);
|
|
const [qInput, setQInput] = useState('');
|
|
const [q, setQ] = useState('');
|
|
|
|
// Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setQ(qInput.trim());
|
|
setPage(1);
|
|
}, 300);
|
|
return () => clearTimeout(t);
|
|
}, [qInput]);
|
|
|
|
const load = useCallback(() => {
|
|
setError(false);
|
|
const search = q ? `&q=${encodeURIComponent(q)}` : '';
|
|
apiGet(`/api/devlogs?page=${page}${search}`).then(setData).catch(() => setError(true));
|
|
}, [page, q]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
window.scrollTo(0, 0);
|
|
}, [load]);
|
|
|
|
// Admin: Devlog aus dem Archiv entfernen (löscht auch die lokalen Bilder)
|
|
async function remove(id) {
|
|
if (!window.confirm('Dieses Devlog aus dem Archiv löschen?')) return;
|
|
try {
|
|
await apiDelete(`/api/devlogs/${id}`);
|
|
load();
|
|
} catch {
|
|
window.alert('Löschen fehlgeschlagen.');
|
|
}
|
|
}
|
|
|
|
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
|
|
|
|
// Monats-Trenner in der Timeline
|
|
let lastMonth = null;
|
|
|
|
return (
|
|
<>
|
|
<section className="page-header">
|
|
<div className="page-bg-text">DEVLOG</div>
|
|
<div className="page-header-grid" aria-hidden="true" />
|
|
<div className="page-header-content">
|
|
<div className="page-tag">// Archiv</div>
|
|
<h1 className="page-title">Devlog</h1>
|
|
<p className="page-subtitle">
|
|
Entwicklungs-Updates, frisch aus dem Editor — automatisch archiviert.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="content">
|
|
<div className="search">
|
|
<span className="search-icon">⌕</span>
|
|
<input
|
|
type="search"
|
|
placeholder="Devlogs durchsuchen …"
|
|
value={qInput}
|
|
onChange={(e) => setQInput(e.target.value)}
|
|
/>
|
|
{q && data && <span className="search-count">{data.total} Treffer</span>}
|
|
</div>
|
|
|
|
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
|
{!error && !data && <p className="notice">Lade …</p>}
|
|
{data?.total === 0 && (
|
|
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
|
|
)}
|
|
|
|
{data?.total > 0 && (
|
|
<div className="timeline">
|
|
{data.items.map((d) => {
|
|
const { project, footer, body } = splitContent(d.content);
|
|
const month = monthFmt.format(new Date(d.posted_at));
|
|
const showMonth = month !== lastMonth;
|
|
lastMonth = month;
|
|
return (
|
|
<div key={d.message_id}>
|
|
{showMonth && <div className="month-sep">{month}</div>}
|
|
<article className="card">
|
|
<div className="card-meta">
|
|
<span className="card-date">
|
|
{dateFmt.format(new Date(d.posted_at))}
|
|
</span>
|
|
{project && <span className="card-project">{project}</span>}
|
|
<span className="card-rel">{relative(d.posted_at)}</span>
|
|
{me?.admin && (
|
|
<button
|
|
className="card-delete"
|
|
title="Aus dem Archiv löschen"
|
|
onClick={() => remove(d.message_id)}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="card-body">
|
|
<Markdown text={body} />
|
|
</div>
|
|
{d.images?.length > 0 && (
|
|
<div className={`card-images n${Math.min(d.images.length, 4)}`}>
|
|
{d.images.map((src) => (
|
|
<a key={src} href={src} target="_blank" rel="noreferrer">
|
|
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
{footer && (
|
|
<div className="card-footer">
|
|
<Markdown text={footer} />
|
|
</div>
|
|
)}
|
|
</article>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{pages > 1 && (
|
|
<div className="pager">
|
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
|
← Neuere
|
|
</button>
|
|
<span className="pager-info">
|
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
|
</span>
|
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
|
Ältere →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
}
|