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
+24
View File
@@ -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 />} />