Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish
- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs bisherige Embed wenn das Rendering scheitert; Dockerfile installiert fonts-dejavu-core für die Text-Darstellung - Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle), tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab - Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id), Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite) - Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-, Composer- und geplante Posts automatisch in Ankündigungs-Kanälen - brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import Settings from './pages/Settings.jsx';
|
||||
import Profil from './pages/Profil.jsx';
|
||||
import Server from './pages/Server.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||
|
||||
const COMMUNITY_LINKS = [
|
||||
['/galerie', 'Galerie'],
|
||||
@@ -187,6 +188,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
<Route path="/devlogs/:id" element={<DevlogDetail />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/server" element={<Server />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Permalink-Seite für ein einzelnes Devlog (/devlogs/:id) — teilbar mit
|
||||
// Open-Graph-Vorschau (injiziert der Server ins HTML).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { apiGet } from '../api.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { splitContent } from './Devlogs.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
|
||||
export default function DevlogDetail() {
|
||||
const { id } = useParams();
|
||||
const [item, setItem] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [lightbox, setLightbox] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setItem(null);
|
||||
setError(false);
|
||||
apiGet(`/api/devlogs/${id}`).then((d) => setItem(d.item)).catch(() => setError(true));
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* Browser ohne Clipboard-Rechte */ }
|
||||
}
|
||||
|
||||
const parts = item ? splitContent(item.content) : 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">// Devlog</div>
|
||||
<h1 className="page-title">{item ? dateFmt.format(new Date(item.posted_at)) : 'Devlog'}</h1>
|
||||
{parts?.project && <p className="page-subtitle">Projekt: {parts.project}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && (
|
||||
<p className="notice">
|
||||
Devlog nicht gefunden — <Link to="/devlogs" style={{ textDecoration: 'underline' }}>zurück zum Archiv</Link>.
|
||||
</p>
|
||||
)}
|
||||
{!error && !item && <SkeletonCards n={1} />}
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<article className="card">
|
||||
<div className="card-body">
|
||||
<Markdown text={parts.body} />
|
||||
</div>
|
||||
{item.images?.length > 0 && (
|
||||
<div className={`card-images n${Math.min(item.images.length, 4)}`}>
|
||||
{item.images.map((src, i) => (
|
||||
<a key={src} href={src} onClick={(e) => { e.preventDefault(); setLightbox(i); }}>
|
||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parts.footer && (
|
||||
<div className="card-footer">
|
||||
<Markdown text={parts.footer} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<div className="field-row" style={{ marginTop: '1.2rem' }}>
|
||||
<Link className="btn" to="/devlogs">← Alle Devlogs</Link>
|
||||
<button className="btn" onClick={copyLink}>{copied ? '✓ Link kopiert' : '🔗 Link kopieren'}</button>
|
||||
</div>
|
||||
|
||||
{lightbox !== null && (
|
||||
<Lightbox images={item.images} start={lightbox} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function relative(iso) {
|
||||
* - Embed-Titel („Devlog — Projekt") → Projekt-Chip statt Fließtext
|
||||
* - Abschließende Kursiv-Zeile („*X Commits heute …*") → abgesetzte Fußzeile
|
||||
*/
|
||||
function splitContent(raw) {
|
||||
export function splitContent(raw) {
|
||||
const blocks = raw.split(/\n{2,}/);
|
||||
let project = null;
|
||||
let footer = null;
|
||||
@@ -135,6 +135,7 @@ export default function Devlogs({ me }) {
|
||||
</span>
|
||||
{project && <span className="card-project">{project}</span>}
|
||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog">🔗</a>
|
||||
{me?.admin && (
|
||||
<button
|
||||
className="card-delete"
|
||||
|
||||
@@ -733,6 +733,24 @@ export default function Settings({ me }) {
|
||||
|
||||
const tabCommunity = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Geburtstage</h2>
|
||||
<div className="field">
|
||||
<label>Geburtstags-Kanal <span className="field-hint">Member tragen sich mit /geburtstag ein; Gratulation morgens ab 09:00 — leer = aus</span></label>
|
||||
<select value={form.birthday_channel_id ?? ''} onChange={(e) => setForm({ ...form, birthday_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Geburtstags-Rolle <span className="field-hint">gibt's für den Tag, wird am nächsten Morgen wieder entfernt — leer = keine</span></label>
|
||||
<select value={form.birthday_role_id ?? ''} onChange={(e) => setForm({ ...form, birthday_role_id: e.target.value })}>
|
||||
<option value="">— keine Rolle —</option>
|
||||
{roleOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Playtester</h2>
|
||||
<div className="field">
|
||||
|
||||
@@ -1466,3 +1466,10 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
font-family: var(--mono); font-size: .7rem; letter-spacing: .2em;
|
||||
text-transform: uppercase; color: var(--neon);
|
||||
}
|
||||
|
||||
/* Permalink-Icon an der Devlog-Karte */
|
||||
.card-permalink {
|
||||
font-size: .8rem; opacity: .35; transition: opacity .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-permalink:hover { opacity: 1; }
|
||||
|
||||
Reference in New Issue
Block a user