Community-Paket: Playtester-Programm, Starboard, Screenshot-Galerie

- Playtester: /playtester-setup postet Bewerbungs-Embed mit 🧪-Button;
  Toggle vergibt Rolle + DB-Eintrag; Liste + Rollen-Auswahl auf der Setup-Seite
- Starboard: ab konfigurierbarem -Schwellwert Repost als Embed in den
  Best-of-Kanal (Dedupe, kein Selbst-Boarding); neue Intents/Partials für Reaktionen
- Galerie: Bilder aus dem Screenshot-Kanal lokal archiviert (live + /galerie-backfill,
  Delete-Sync, Bot reagiert mit ) → öffentliche /galerie-Seite mit Hover-Grid
- Setup-Seite: Sektion // Community (Rolle, Starboard-Kanal + Schwellwert, Screenshot-Kanal)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:34:34 +02:00
co-authored by Claude Opus 4.8
parent 6d8f383aed
commit a3c47656c5
13 changed files with 563 additions and 12 deletions
+3
View File
@@ -4,6 +4,7 @@ import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx';
import Changelog from './pages/Changelog.jsx';
import Roadmap from './pages/Roadmap.jsx';
import Galerie from './pages/Galerie.jsx';
import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx';
@@ -72,6 +73,7 @@ export default function App() {
<nav className="nav-links">
<NavLink to="/devlogs">Devlog</NavLink>
<NavLink to="/roadmap">Roadmap</NavLink>
<NavLink to="/galerie">Galerie</NavLink>
<NavLink to="/changelog">Changelog</NavLink>
{me.admin && <NavLink to="/commits">Commits</NavLink>}
{me.admin && <NavLink to="/settings">Setup</NavLink>}
@@ -97,6 +99,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/devlogs" replace />} />
<Route path="/devlogs" element={<Devlogs me={me} />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/galerie" element={<Galerie />} />
<Route path="/changelog" element={<Changelog />} />
<Route path="/commits" element={<Commits me={me} />} />
<Route path="/settings" element={<Settings me={me} />} />
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
export default function Galerie() {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(false);
useEffect(() => {
setError(false);
apiGet(`/api/gallery?page=${page}`).then(setData).catch(() => setError(true));
window.scrollTo(0, 0);
}, [page]);
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
const shots = (data?.items ?? []).flatMap((item) =>
item.images.map((src) => ({ src, author: item.author_name, date: item.posted_at, key: src }))
);
return (
<>
<section className="page-header">
<div className="page-bg-text">GALERIE</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Community</div>
<h1 className="page-title">Galerie</h1>
<p className="page-subtitle">Screenshots aus der Community direkt aus dem Discord.</p>
</div>
</section>
<section className="content">
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{data?.total === 0 && <p className="notice">Noch keine Screenshots poste welche im Discord!</p>}
<div className="gallery-grid">
{shots.map((s) => (
<a className="shot" href={s.src} target="_blank" rel="noreferrer" key={s.key}>
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
<span className="shot-meta">
<span className="shot-author">{s.author}</span>
<span className="shot-date">{dateFmt.format(new Date(s.date))}</span>
</span>
</a>
))}
</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>
</>
);
}
+62
View File
@@ -26,6 +26,8 @@ export default function Settings({ me }) {
const [form, setForm] = useState(null);
const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null);
// Community
const [playtesters, setPlaytesters] = useState([]);
// API-Keys
const [keys, setKeys] = useState([]);
const [newKeyName, setNewKeyName] = useState('');
@@ -41,6 +43,7 @@ export default function Settings({ me }) {
})
.catch((e) => setError(e.status));
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
}, [me.admin]);
async function createKey() {
@@ -295,6 +298,65 @@ export default function Settings({ me }) {
</div>
</div>
{/* Community */}
<div className="settings-section">
<h2 className="settings-title">// Community</h2>
<div className="field">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<select
value={form.playtester_role_id ?? ''}
onChange={(e) => setForm({ ...form, playtester_role_id: e.target.value })}
>
<option value=""> deaktiviert </option>
{(data.roles ?? []).map((r) => (
<option key={r.id} value={r.id}>@{r.name} {r.guild}</option>
))}
</select>
{playtesters.length > 0 && (
<div className="key-list" style={{ marginTop: '.8rem' }}>
{playtesters.map((p) => (
<div className="key-row" key={p.user_id}>
<span className="key-name">{p.username}</span>
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
</div>
))}
</div>
)}
</div>
<div className="field">
<label>Starboard-Kanal <span className="field-hint">Nachrichten mit genug landen hier; leer = aus</span></label>
<div className="field-row">
<select
value={form.starboard_channel_id ?? ''}
onChange={(e) => setForm({ ...form, starboard_channel_id: e.target.value })}
>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
<input
type="text"
style={{ width: '7rem', flex: 'none' }}
title="Schwellwert (Anzahl ⭐)"
value={form.starboard_threshold}
onChange={(e) => setForm({ ...form, starboard_threshold: e.target.value })}
/>
</div>
</div>
<div className="field">
<label>Screenshot-Kanal <span className="field-hint">Bilder landen in der öffentlichen Galerie; Historie: /galerie-backfill</span></label>
<select
value={form.screenshot_channel_id ?? ''}
onChange={(e) => setForm({ ...form, screenshot_channel_id: e.target.value })}
>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
</div>
{/* Backups */}
<div className="settings-section">
<h2 className="settings-title">// Backups</h2>
+31
View File
@@ -398,6 +398,37 @@ body::after {
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
.card-footer a:hover { color: var(--neon); }
/* ── GALERIE ───────────────────────────────────────── */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: .75rem;
}
.shot {
position: relative; display: block; overflow: hidden;
border: 1px solid rgba(255, 255, 255, .08);
aspect-ratio: 16 / 9;
background: #000;
transition: border-color .2s;
}
.shot:hover { border-color: var(--neon); }
.shot img {
width: 100%; height: 100%; object-fit: cover; display: block;
transition: transform .3s;
}
.shot:hover img { transform: scale(1.04); }
.shot-meta {
position: absolute; inset: auto 0 0 0;
display: flex; justify-content: space-between; align-items: baseline;
padding: .4rem .6rem;
background: linear-gradient(to top, rgba(0, 0, 0, .85), transparent);
font-family: var(--mono); font-size: .62rem; letter-spacing: .1em;
opacity: 0; transition: opacity .2s;
}
.shot:hover .shot-meta { opacity: 1; }
.shot-author { color: var(--neon); }
.shot-date { color: var(--muted); }
/* ── ROADMAP ───────────────────────────────────────── */
.roadmap-group { margin-bottom: 2.25rem; }
.roadmap-group .settings-title { margin-bottom: 1rem; }