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:
@@ -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}`}>
|
||||
<span className="wish-score">👍 {w.score}</span>
|
||||
<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>
|
||||
))}
|
||||
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||||
Eigene Idee? Im Discord einfach <code>/wunsch</code> benutzen.
|
||||
</p>
|
||||
{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 <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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user