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:
2026-07-30 21:52:59 +02:00
co-authored by Claude Fable 5
parent 3bfa559f2e
commit aa12dff7ab
11 changed files with 551 additions and 15 deletions
+164
View File
@@ -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>
</>
);
}