Member-Bereich: Profilseite, Rollen-Selfservice, Web-Voting, Member-Gate
Der Discord-Login ist jetzt für alle Member nützlich: - /profil: Rang + XP-Fortschritt, Discord-Rollen als Chips, Playtester- Badge, eigener Alpha-Key mit Kopier-Button - Rollen-Selfservice: veröffentlichte Rollen-Menüs direkt im Browser togglen (gleiche Exklusiv-Logik wie die Discord-Buttons; nur Rollen aus aktiven Menüs erlaubt) - Wunsch-Voting im Web: auf /roadmap Wünsche einreichen (postet wie /wunsch in den Voting-Kanal) und upvoten (wish_votes-Tabelle, ein Vote pro Member, unabhängig von Discord-👍) - Member-Gate: Login nur für Mitglieder der konfigurierten Guild; Abgewiesene sehen einen Banner mit Discord-Invite-Link (neue Settings member_gate_enabled + discord_invite_url im System-Tab) - Membership-Check mit 5-Minuten-Cache, Owner immer erlaubt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
||||
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
|
||||
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
|
||||
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
|
||||
| 👤 **Member-Bereich** | Discord-Login für alle Member: `/profil` (Rang + XP-Balken, Rollen, Playtester-Badge, eigener Alpha-Key), Rollen-Selfservice im Browser (nutzt die Rollen-Menüs), Wünsche einreichen + upvoten auf der Roadmap; **Member-Gate**: Login nur für Server-Mitglieder (abweisbare bekommen den Invite-Link) |
|
||||
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
||||
|
||||
### Webinterface (`bot.d4rkst3r.de`)
|
||||
@@ -61,6 +62,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
||||
| `/events` | öffentlich | Discord-Events (Playtests, Streams …) |
|
||||
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key |
|
||||
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
||||
| `/settings` | nur Admin | **Setup-Seite** — siehe unten |
|
||||
|
||||
@@ -262,6 +264,7 @@ urllib.request.urlopen(req)
|
||||
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
|
||||
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
|
||||
| `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen |
|
||||
| `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
|
||||
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
|
||||
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
|
||||
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
|
||||
|
||||
@@ -9,6 +9,7 @@ import Level from './pages/Level.jsx';
|
||||
import Events from './pages/Events.jsx';
|
||||
import Commits from './pages/Commits.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import Profil from './pages/Profil.jsx';
|
||||
|
||||
function DiscordMark() {
|
||||
return (
|
||||
@@ -50,6 +51,9 @@ export default function App() {
|
||||
// Login-Status einmal beim Laden holen
|
||||
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
|
||||
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
|
||||
const [invite, setInvite] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/me')
|
||||
@@ -57,6 +61,10 @@ export default function App() {
|
||||
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (gated) apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
|
||||
}, [gated]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
@@ -79,6 +87,7 @@ export default function App() {
|
||||
<NavLink to="/level">Level</NavLink>
|
||||
<NavLink to="/events">Events</NavLink>
|
||||
<NavLink to="/changelog">Changelog</NavLink>
|
||||
{me.user && <NavLink to="/profil">Profil</NavLink>}
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||
</nav>
|
||||
@@ -99,6 +108,20 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
{gated && (
|
||||
<div className="gate-banner">
|
||||
<span>
|
||||
🚪 Der Login ist Mitgliedern des Discord-Servers vorbehalten —
|
||||
tritt erst dem Server bei und logg dich dann nochmal ein.
|
||||
</span>
|
||||
{invite && (
|
||||
<a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">
|
||||
Zum Discord
|
||||
</a>
|
||||
)}
|
||||
<button className="card-delete" onClick={() => setGated(false)} title="Ausblenden">✕</button>
|
||||
</div>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
@@ -107,6 +130,7 @@ export default function App() {
|
||||
<Route path="/level" element={<Level />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/changelog" element={<Changelog />} />
|
||||
<Route path="/profil" element={<Profil me={me} />} />
|
||||
<Route path="/commits" element={<Commits me={me} />} />
|
||||
<Route path="/settings" element={<Settings me={me} />} />
|
||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Member-Profil: Rang & XP, Discord-Rollen, Alpha-Key, Playtester-Status
|
||||
// + Rollen-Selfservice (Rollen-Menüs direkt im Browser togglen).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiGet, apiPost } from '../api.js';
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
.format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Profil({ me }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [menus, setMenus] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/profile').then(setProfile).catch((e) => setError(e?.message ?? 'Fehler'));
|
||||
apiGet('/api/myroles').then((d) => setMenus(d.menus)).catch(() => {});
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
async function toggleRole(menuId, roleId) {
|
||||
setBusy(`${menuId}:${roleId}`);
|
||||
try {
|
||||
const res = await apiPost('/api/myroles/toggle', { menu_id: menuId, role_id: roleId });
|
||||
setMenus((old) => old.map((m) => m.id !== menuId ? m : {
|
||||
...m,
|
||||
entries: m.entries.map((e) => {
|
||||
if (e.role_id === roleId) return { ...e, has: res.has };
|
||||
// Exklusiv: die anderen sind jetzt sicher weg
|
||||
return m.exclusive && res.has ? { ...e, has: false } : e;
|
||||
}),
|
||||
}));
|
||||
// Rollen-Chips oben mitziehen
|
||||
apiGet('/api/profile').then(setProfile).catch(() => {});
|
||||
} catch { /* Server meldet schon */ }
|
||||
setBusy('');
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.alphaKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* Browser ohne Clipboard-Rechte */ }
|
||||
}
|
||||
|
||||
if (!me.loading && !me.user) {
|
||||
return (
|
||||
<section className="page-header">
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Profil</div>
|
||||
<h1 className="page-title">Nicht eingeloggt</h1>
|
||||
<p className="page-subtitle">
|
||||
<a href="/auth/login" style={{ textDecoration: 'underline' }}>Mit Discord einloggen</a>, um dein Profil zu sehen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const pct = profile?.level ? Math.floor((profile.level.progress / profile.level.needed) * 100) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div className="page-bg-text">PROFIL</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Member</div>
|
||||
<h1 className="page-title">Profil</h1>
|
||||
<p className="page-subtitle">Dein Stand auf dem Server — Rang, Rollen und Keys.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">⚠️ {error}</p>}
|
||||
{!profile && !error && <p className="notice">Lade …</p>}
|
||||
|
||||
{profile && (
|
||||
<>
|
||||
{/* Kopf-Karte */}
|
||||
<div className="profile-card">
|
||||
{profile.user.avatar && <img className="profile-avatar" src={profile.user.avatar} alt="" />}
|
||||
<div className="profile-head">
|
||||
<h2 className="profile-name">{profile.user.displayName}</h2>
|
||||
<p className="profile-meta">
|
||||
Dabei seit {fmtDate(profile.user.joinedAt)}
|
||||
{profile.playtester && <span className="profile-badge">🧪 Playtester</span>}
|
||||
</p>
|
||||
{profile.roles.length > 0 && (
|
||||
<div className="profile-roles">
|
||||
{profile.roles.map((r) => (
|
||||
<span key={r.id} className="role-chip" style={r.color ? { borderColor: r.color, color: r.color } : undefined}>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level */}
|
||||
{profile.level && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">
|
||||
// Level {profile.level.level}
|
||||
{profile.level.rank ? ` — Platz #${profile.level.rank}` : ''}
|
||||
</h2>
|
||||
<div className="xp-bar">
|
||||
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
<p className="field-hint" style={{ marginTop: '.4rem' }}>
|
||||
{profile.level.progress.toLocaleString('de-DE')} / {profile.level.needed.toLocaleString('de-DE')} XP
|
||||
bis Level {profile.level.level + 1} ({pct}%) · gesamt {profile.level.xp.toLocaleString('de-DE')} XP
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alpha-Key */}
|
||||
{profile.alphaKey && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">// Dein Alpha-Key</h2>
|
||||
<div className="field-row">
|
||||
<code className="alpha-key">{profile.alphaKey}</code>
|
||||
<button className="btn btn-mini" onClick={copyKey}>{copied ? '✓ Kopiert' : 'Kopieren'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rollen-Selfservice */}
|
||||
{menus.length > 0 && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">// Rollen wählen</h2>
|
||||
{menus.map((m) => (
|
||||
<div className="rolemenu-box" key={m.id}>
|
||||
<h3 className="rolemenu-title">{m.title}</h3>
|
||||
{m.description && <p className="field-hint">{m.description}</p>}
|
||||
{m.exclusive && <p className="field-hint">Nur eine Rolle aus diesem Menü gleichzeitig.</p>}
|
||||
<div className="rolemenu-buttons">
|
||||
{m.entries.map((e) => (
|
||||
<button
|
||||
key={e.role_id}
|
||||
className={`btn rolemenu-btn${e.has ? ' active' : ''}`}
|
||||
disabled={busy === `${m.id}:${e.role_id}`}
|
||||
onClick={() => toggleRole(m.id, e.role_id)}
|
||||
>
|
||||
{e.emoji ? `${e.emoji} ` : ''}{e.label}{e.has ? ' ✓' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiGet } from '../api.js';
|
||||
import { apiGet, apiPost } from '../api.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
|
||||
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
||||
@@ -83,16 +83,43 @@ function Heatmap({ days }) {
|
||||
export default function Roadmap() {
|
||||
const [data, setData] = useState(null);
|
||||
const [wishes, setWishes] = useState([]);
|
||||
const [canVote, setCanVote] = useState(false);
|
||||
const [idea, setIdea] = useState('');
|
||||
const [submitMsg, setSubmitMsg] = useState('');
|
||||
const [heatmap, setHeatmap] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
||||
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
|
||||
apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {});
|
||||
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
async function vote(messageId) {
|
||||
try {
|
||||
const res = await apiPost(`/api/wishes/${messageId}/vote`);
|
||||
setWishes((old) => old.map((w) => w.message_id !== messageId
|
||||
? w
|
||||
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
||||
} catch (e) {
|
||||
if (e.status === 403) setSubmitMsg('⚠️ Voten dürfen nur Discord-Member.');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWish() {
|
||||
const text = idea.trim();
|
||||
if (text.length < 5) { setSubmitMsg('⚠️ Etwas mehr Text bitte.'); return; }
|
||||
try {
|
||||
await apiPost('/api/wishes', { idea: text });
|
||||
setIdea('');
|
||||
setSubmitMsg('✅ Wunsch eingereicht — er steht jetzt auch im Discord zur Abstimmung!');
|
||||
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
|
||||
} catch (e) {
|
||||
setSubmitMsg(e.status === 403 ? '⚠️ Einreichen dürfen nur Discord-Member.' : '⚠️ Konnte den Wunsch nicht einreichen.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
@@ -116,19 +143,42 @@ export default function Roadmap() {
|
||||
|
||||
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
|
||||
|
||||
{wishes.length > 0 && (
|
||||
{(wishes.length > 0 || canVote) && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">// Community-Wünsche</h2>
|
||||
{wishes.map((w, i) => (
|
||||
<div className="wish-row" key={`${w.created_at}-${i}`}>
|
||||
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
|
||||
{canVote && w.message_id ? (
|
||||
<button
|
||||
className={`wish-vote${w.voted ? ' voted' : ''}`}
|
||||
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
|
||||
onClick={() => vote(w.message_id)}
|
||||
>
|
||||
👍 {w.score}
|
||||
</button>
|
||||
) : (
|
||||
<span className="wish-score">👍 {w.score}</span>
|
||||
)}
|
||||
<span className="wish-idea">{w.idea}</span>
|
||||
<span className="wish-author">{w.author}</span>
|
||||
</div>
|
||||
))}
|
||||
{canVote ? (
|
||||
<div className="wish-form">
|
||||
<input
|
||||
type="text" maxLength={500} placeholder="Deine Idee für den Server …"
|
||||
value={idea}
|
||||
onChange={(e) => setIdea(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
|
||||
/>
|
||||
<button className="btn btn-save" onClick={submitWish}>Einreichen</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||||
Eigene Idee? Im Discord einfach <code>/wunsch</code> benutzen.
|
||||
Eigene Idee? Im Discord <code>/wunsch</code> benutzen — oder hier einloggen und direkt abstimmen.
|
||||
</p>
|
||||
)}
|
||||
{submitMsg && <p className="notice" style={{ padding: '.6rem 0 0' }}>{submitMsg}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1199,6 +1199,21 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Web-Login (Member)</h2>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={form.member_gate_enabled} onChange={(e) => setForm({ ...form, member_gate_enabled: e.target.checked })} />
|
||||
<span>Login nur für Discord-Member</span>
|
||||
<span className="field-hint">Nicht-Member werden nach dem Discord-Login abgewiesen (braucht gesetzte Guild-ID)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Discord-Invite-Link <span className="field-hint">wird Abgewiesenen als „Zum Discord"-Button angezeigt</span></label>
|
||||
<input type="text" placeholder="https://discord.gg/…" value={form.discord_invite_url} onChange={(e) => setForm({ ...form, discord_invite_url: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||||
<div className="field">
|
||||
|
||||
@@ -1113,3 +1113,55 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
.gamesel-item:hover, .gamesel-item.active { background: var(--bg3); color: var(--neon); }
|
||||
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
|
||||
.gamesel-item:hover .gamesel-id { color: var(--muted); }
|
||||
|
||||
/* ── Member-Bereich: Gate-Banner, Profil, Web-Voting ── */
|
||||
.gate-banner {
|
||||
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
|
||||
margin: 1.2rem auto 0; max-width: 900px; padding: .8rem 1.1rem;
|
||||
background: rgba(255, 77, 0, 0.08); border: 1px solid rgba(255, 77, 0, 0.4);
|
||||
border-radius: 10px; font-family: var(--body);
|
||||
}
|
||||
.gate-banner span { flex: 1; min-width: 240px; }
|
||||
|
||||
.profile-card {
|
||||
display: flex; gap: 1.4rem; align-items: flex-start;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 1.4rem; margin-bottom: 2rem;
|
||||
}
|
||||
.profile-avatar { width: 84px; height: 84px; border-radius: 50%; border: 2px solid var(--neon); }
|
||||
.profile-name { font-family: var(--display); font-size: 2rem; letter-spacing: .05em; margin: 0; }
|
||||
.profile-meta { color: var(--muted); margin: .2rem 0 0; font-family: var(--body); }
|
||||
.profile-badge {
|
||||
margin-left: .8rem; padding: .1rem .55rem; border-radius: 999px;
|
||||
border: 1px solid var(--success); color: var(--success); font-size: .85rem;
|
||||
}
|
||||
.profile-roles { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .8rem; }
|
||||
.role-chip {
|
||||
padding: .15rem .6rem; border-radius: 999px; font-size: .85rem;
|
||||
border: 1px solid var(--border); color: var(--text); font-family: var(--body);
|
||||
}
|
||||
.xp-bar { height: 12px; border-radius: 999px; background: var(--bg3); border: 1px solid var(--border); overflow: hidden; }
|
||||
.xp-fill { height: 100%; background: linear-gradient(90deg, var(--neon), var(--neon2)); }
|
||||
.alpha-key {
|
||||
font-family: var(--mono); background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: .5rem .8rem; letter-spacing: .08em;
|
||||
}
|
||||
.rolemenu-box { margin-bottom: 1.4rem; }
|
||||
.rolemenu-title { font-family: var(--body); font-weight: 600; font-size: 1.1rem; margin: 0 0 .2rem; }
|
||||
.rolemenu-buttons { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
|
||||
.rolemenu-btn.active { border-color: var(--neon); color: var(--neon); }
|
||||
|
||||
.wish-vote {
|
||||
background: var(--bg3); border: 1px solid var(--border); border-radius: 999px;
|
||||
color: var(--muted); font-family: var(--mono); padding: .25rem .7rem; cursor: pointer;
|
||||
white-space: nowrap; transition: border-color .15s, color .15s;
|
||||
}
|
||||
.wish-vote:hover { border-color: var(--neon); color: var(--text); }
|
||||
.wish-vote.voted { border-color: var(--neon); color: var(--neon); }
|
||||
.wish-form { display: flex; gap: .6rem; margin-top: 1rem; }
|
||||
.wish-form input { flex: 1; }
|
||||
@media (max-width: 640px) {
|
||||
.profile-card { flex-direction: column; align-items: center; text-align: center; }
|
||||
.profile-roles { justify-content: center; }
|
||||
.wish-form { flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?')
|
||||
const listPlaytestersStmt = db.prepare(
|
||||
'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at'
|
||||
);
|
||||
const hasPlaytesterStmt = db.prepare('SELECT 1 FROM playtesters WHERE user_id = ?');
|
||||
export const addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0;
|
||||
export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0;
|
||||
export const listPlaytesters = () => listPlaytestersStmt.all();
|
||||
export const isPlaytester = (userId) => Boolean(hasPlaytesterStmt.get(userId));
|
||||
|
||||
const hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?');
|
||||
const insertStarboard = db.prepare(
|
||||
@@ -206,13 +208,38 @@ const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VAL
|
||||
const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?');
|
||||
const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?');
|
||||
const topWishesStmt = db.prepare(
|
||||
'SELECT idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
|
||||
'SELECT message_id, idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
|
||||
);
|
||||
export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea);
|
||||
export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId));
|
||||
export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId);
|
||||
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
|
||||
|
||||
// Web-Votes auf Wünsche (Discord-👍 laufen separat über Reactions)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS wish_votes (
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
`);
|
||||
const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE message_id = ? AND user_id = ?');
|
||||
const insertWishVote = db.prepare('INSERT OR IGNORE INTO wish_votes (message_id, user_id) VALUES (?, ?)');
|
||||
const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE message_id = ? AND user_id = ?');
|
||||
const myWishVotesStmt = db.prepare('SELECT message_id FROM wish_votes WHERE user_id = ?');
|
||||
/** Web-Vote togglen; passt den Score mit an. @returns {boolean} true = jetzt gevotet */
|
||||
export function toggleWishVote(messageId, userId) {
|
||||
if (hasWishVoteStmt.get(messageId, userId)) {
|
||||
deleteWishVote.run(messageId, userId);
|
||||
wishScoreStmt.run(-1, messageId);
|
||||
return false;
|
||||
}
|
||||
insertWishVote.run(messageId, userId);
|
||||
wishScoreStmt.run(1, messageId);
|
||||
return true;
|
||||
}
|
||||
export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.message_id);
|
||||
|
||||
const insertGiveaway = db.prepare(`
|
||||
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)
|
||||
VALUES (@message_id, @channel_id, @prize, @winners, @ends_at)
|
||||
|
||||
@@ -161,6 +161,16 @@ export function levelRewards() {
|
||||
.sort((a, b) => a.level - b.level);
|
||||
}
|
||||
|
||||
/** Member-Gate: Web-Login nur für Discord-Member (Default: an, wenn Guild gesetzt) */
|
||||
export function memberGateEnabled() {
|
||||
return getSetting('member_gate_enabled') !== '0';
|
||||
}
|
||||
|
||||
/** Discord-Invite-Link für die „Tritt erst dem Server bei"-Seite — leer = keiner */
|
||||
export function discordInviteUrl() {
|
||||
return getSetting('discord_invite_url') || null;
|
||||
}
|
||||
|
||||
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
|
||||
export function votingChannelId() {
|
||||
return getSetting('voting_channel_id') || null;
|
||||
|
||||
+178
-3
@@ -13,6 +13,7 @@ import {
|
||||
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
|
||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||
saveTemplate, listTemplates, deleteTemplate,
|
||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||
} from '../db.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { sanitizeEmbed } from './api-v1.js';
|
||||
@@ -20,9 +21,10 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
|
||||
import { computeNextRun } from './scheduled-posts.js';
|
||||
import { config } from '../config.js';
|
||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken } from '../runtime-settings.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl } from '../runtime-settings.js';
|
||||
import { xpForLevel } from '../bot/levels.js';
|
||||
import { getMilestones } from '../gitea-api.js';
|
||||
import { getSessionUser, isAdmin } from './auth.js';
|
||||
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -110,7 +112,14 @@ export function registerApiRoutes(app, client) {
|
||||
});
|
||||
|
||||
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
|
||||
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
|
||||
app.get('/api/wishes', async (request) => {
|
||||
const user = getSessionUser(request);
|
||||
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
||||
return {
|
||||
wishes: topWishes(20).map((w) => ({ ...w, voted: mine.has(w.message_id) })),
|
||||
canVote: Boolean(user),
|
||||
};
|
||||
});
|
||||
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
||||
|
||||
// Level-Bestenliste + Server-Aktivität — öffentlich
|
||||
@@ -302,6 +311,8 @@ ${rssItems}
|
||||
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
|
||||
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
|
||||
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
|
||||
member_gate_enabled: getSetting('member_gate_enabled') !== '0',
|
||||
discord_invite_url: getSetting('discord_invite_url') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,6 +410,16 @@ ${rssItems}
|
||||
if (body.sticky_roles_enabled !== undefined) {
|
||||
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0');
|
||||
}
|
||||
if (body.member_gate_enabled !== undefined) {
|
||||
setSetting('member_gate_enabled', body.member_gate_enabled ? '1' : '0');
|
||||
}
|
||||
if (body.discord_invite_url !== undefined) {
|
||||
const value = String(body.discord_invite_url).trim();
|
||||
if (value && !/^https:\/\//.test(value)) {
|
||||
return reply.code(400).send({ error: 'discord_invite_url: https-Link erwartet' });
|
||||
}
|
||||
setSetting('discord_invite_url', value);
|
||||
}
|
||||
if (body.levels_enabled !== undefined) {
|
||||
setSetting('levels_enabled', body.levels_enabled ? '1' : '0');
|
||||
}
|
||||
@@ -1006,6 +1027,160 @@ ${rssItems}
|
||||
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
|
||||
});
|
||||
|
||||
// --- Member-Bereich: Profil, Rollen-Selfservice, Wunsch-Voting ---
|
||||
|
||||
// Membership kurz cachen, damit nicht jeder Klick die Discord-API trifft
|
||||
const memberCheckCache = new Map(); // userId → { ok, at }
|
||||
async function requireMember(request, reply) {
|
||||
const user = getSessionUser(request);
|
||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||
const cached = memberCheckCache.get(user.id);
|
||||
if (cached && Date.now() - cached.at < 5 * 60_000) {
|
||||
return cached.ok ? null : reply.code(403).send({ error: 'not a member' });
|
||||
}
|
||||
const ok = await isGuildMember(client, user.id);
|
||||
memberCheckCache.set(user.id, { ok, at: Date.now() });
|
||||
return ok ? null : reply.code(403).send({ error: 'not a member' });
|
||||
}
|
||||
|
||||
/** Guild-Member des eingeloggten Users holen (null wenn nicht auffindbar) */
|
||||
async function fetchSelfMember(request) {
|
||||
const user = getSessionUser(request);
|
||||
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
|
||||
if (!user || !guildId) return null;
|
||||
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
|
||||
return guild ? await guild.members.fetch(user.id).catch(() => null) : null;
|
||||
}
|
||||
|
||||
app.get('/api/invite', async () => ({ invite: discordInviteUrl() }));
|
||||
|
||||
app.get('/api/profile', async (request, reply) => {
|
||||
if (await requireMember(request, reply)) return;
|
||||
const user = getSessionUser(request);
|
||||
const member = await fetchSelfMember(request);
|
||||
|
||||
const roles = member
|
||||
? [...member.roles.cache.values()]
|
||||
.filter((r) => r.id !== member.guild.id)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((r) => ({ id: r.id, name: r.name, color: r.hexColor === '#000000' ? null : r.hexColor }))
|
||||
: [];
|
||||
|
||||
const row = getLevelRow(user.id);
|
||||
let level = null;
|
||||
if (row) {
|
||||
const rank = topLevels(1000).findIndex((l) => l.user_id === user.id) + 1;
|
||||
const base = xpForLevel(row.level);
|
||||
const next = xpForLevel(row.level + 1);
|
||||
level = {
|
||||
level: row.level, xp: row.xp, rank: rank || null,
|
||||
progress: row.xp - base, needed: next - base,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
...user,
|
||||
displayName: member?.displayName ?? user.username,
|
||||
avatar: member?.displayAvatarURL?.({ size: 128 }) ?? user.avatar,
|
||||
joinedAt: member?.joinedAt?.toISOString?.() ?? null,
|
||||
},
|
||||
roles,
|
||||
level,
|
||||
playtester: isPlaytester(user.id),
|
||||
alphaKey: alphaKeyOf(user.id),
|
||||
};
|
||||
});
|
||||
|
||||
// Veröffentlichte Rollen-Menüs + eigener Rollen-Stand
|
||||
app.get('/api/myroles', async (request, reply) => {
|
||||
if (await requireMember(request, reply)) return;
|
||||
const member = await fetchSelfMember(request);
|
||||
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
|
||||
|
||||
const menus = listRoleMenus()
|
||||
.filter((m) => m.message_id) // nur veröffentlichte
|
||||
.map((m) => ({
|
||||
id: m.id, title: m.title, description: m.description, exclusive: Boolean(m.exclusive),
|
||||
entries: JSON.parse(m.entries || '[]').map((e) => ({
|
||||
role_id: e.role_id, label: e.label, emoji: e.emoji ?? '', style: e.style ?? 'secondary',
|
||||
has: member.roles.cache.has(e.role_id),
|
||||
})),
|
||||
}));
|
||||
return { menus };
|
||||
});
|
||||
|
||||
// Rolle togglen — nur Rollen aus veröffentlichten Menüs, Exklusiv-Logik wie im Button
|
||||
app.post('/api/myroles/toggle', async (request, reply) => {
|
||||
if (await requireMember(request, reply)) return;
|
||||
const menuId = Number(request.body?.menu_id);
|
||||
const roleId = String(request.body?.role_id ?? '');
|
||||
const menu = getRoleMenu(menuId);
|
||||
const entries = menu ? JSON.parse(menu.entries || '[]') : [];
|
||||
const entry = entries.find((e) => e.role_id === roleId);
|
||||
if (!menu?.message_id || !entry) {
|
||||
return reply.code(400).send({ error: 'Rolle gehört zu keinem aktiven Menü' });
|
||||
}
|
||||
const member = await fetchSelfMember(request);
|
||||
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
|
||||
|
||||
try {
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
await member.roles.remove(roleId);
|
||||
return { has: false, label: entry.label };
|
||||
}
|
||||
if (menu.exclusive) {
|
||||
for (const other of entries) {
|
||||
if (other.role_id !== roleId && member.roles.cache.has(other.role_id)) {
|
||||
await member.roles.remove(other.role_id).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await member.roles.add(roleId);
|
||||
return { has: true, label: entry.label };
|
||||
} catch {
|
||||
return reply.code(502).send({ error: 'Rolle konnte nicht geändert werden — Bot-Rechte prüfen' });
|
||||
}
|
||||
});
|
||||
|
||||
// Wunsch einreichen (postet wie /wunsch in den Voting-Kanal)
|
||||
app.post('/api/wishes', async (request, reply) => {
|
||||
if (await requireMember(request, reply)) return;
|
||||
const user = getSessionUser(request);
|
||||
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
|
||||
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
|
||||
const channelId = votingChannelId();
|
||||
if (!channelId) return reply.code(400).send({ error: 'Feature-Voting ist nicht aktiviert' });
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel?.isTextBased()) return reply.code(500).send({ error: 'Voting-Kanal nicht gefunden' });
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(brandColor())
|
||||
.setAuthor({ name: user.username, iconURL: user.avatar ?? undefined })
|
||||
.setTitle('💡 Feature-Wunsch')
|
||||
.setDescription(idea)
|
||||
.setURL(`${publicUrl()}/roadmap`)
|
||||
.setFooter({
|
||||
text: `${brandFooter('VOTING')} • 👍 = will ich!`,
|
||||
iconURL: client.user?.displayAvatarURL?.({ size: 64 }),
|
||||
});
|
||||
const message = await channel.send({ embeds: [embed] });
|
||||
await message.react('👍').catch(() => {});
|
||||
saveWish(message.id, user.username, idea);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Web-Vote togglen (unabhängig von Discord-👍)
|
||||
app.post('/api/wishes/:messageId/vote', async (request, reply) => {
|
||||
if (await requireMember(request, reply)) return;
|
||||
const user = getSessionUser(request);
|
||||
const messageId = String(request.params.messageId);
|
||||
if (!topWishes(1000).some((w) => w.message_id === messageId)) {
|
||||
return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
|
||||
}
|
||||
return { voted: toggleWishVote(messageId, user.id) };
|
||||
});
|
||||
|
||||
// --- Team-Verwaltung (nur Owner) ---
|
||||
|
||||
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
|
||||
|
||||
+18
-2
@@ -1,7 +1,7 @@
|
||||
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { publicUrl } from '../runtime-settings.js';
|
||||
import { publicUrl, discordGuildId, memberGateEnabled } from '../runtime-settings.js';
|
||||
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const SESSION_COOKIE = 'd4rkbot_session';
|
||||
@@ -28,7 +28,18 @@ export function isAdmin(user) {
|
||||
return user?.id === config.adminDiscordId;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app) {
|
||||
/** Member-Gate: ist die User-ID Mitglied der konfigurierten Guild? (Owner immer) */
|
||||
export async function isGuildMember(client, userId) {
|
||||
if (userId === config.adminDiscordId) return true;
|
||||
const guildId = discordGuildId();
|
||||
if (!guildId) return true; // ohne Guild-Konfig kein Gate
|
||||
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) return true; // Bot nicht auf der Guild → Gate nicht erzwingbar
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
return Boolean(member);
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app, client) {
|
||||
// Login: mit CSRF-State zu Discord weiterleiten
|
||||
app.get('/auth/login', async (request, reply) => {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
@@ -81,6 +92,11 @@ export function registerAuthRoutes(app) {
|
||||
}
|
||||
const me = await userRes.json();
|
||||
|
||||
// Member-Gate: nur Mitglieder des Discord-Servers bekommen eine Session
|
||||
if (memberGateEnabled() && !(await isGuildMember(client, me.id))) {
|
||||
return reply.clearCookie(STATE_COOKIE, { path: '/auth' }).redirect('/?gate=1');
|
||||
}
|
||||
|
||||
// Nur das Nötigste in die Session — keine Tokens speichern
|
||||
const session = {
|
||||
id: me.id,
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export async function startWebServer(client) {
|
||||
// Multipart für den Devlog-Endpoint (Bilder von devlog.py, max 10 MB pro Datei)
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
|
||||
|
||||
registerAuthRoutes(app);
|
||||
registerAuthRoutes(app, client);
|
||||
registerApiRoutes(app, client);
|
||||
registerApiV1(app, client);
|
||||
registerDevlogEndpoint(app, client);
|
||||
|
||||
Reference in New Issue
Block a user