Umsetzung des Entwurfs aus design_handoff_redesign/ -- beide Auftritte (Produktseite und Community-Hub) plus Dashboard. Farbe #0a0a0a + Neon-Gelb -> #131316 + Orange-Verlauf aus dem Logo Schrift Bebas/Barlow/ShareTech -> Chakra Petch + IBM Plex Mono Form border-radius -> clip-path, oben links + unten rechts Rauschen, Scanlines, Hintergrundschrift, Kopf-Raster und die Partikel sind raus; der Schein von oben rechts ersetzt sie. Die Abschraegung ist ein System und keine sechzig Einzelregeln: eine clip-path-Regel mit --bv, jede Komponente setzt nur die Tiefe. Der Farbdurchstich ging durch alle ~55 Abschnitte der style.css, nicht nur durch die fuenf gezeichneten Seiten -- sonst waeren Galerie, Level, Composer und Emoji-Auswahl im alten Gelb stehen geblieben. Von --neon ist nichts uebrig. Die Schriften kommen weiter self-hosted ueber @fontsource. Der Entwurf schlaegt einen Google-Fonts-Link vor; das waere eine Fremdanfrage mehr, wo der Aufbau bisher ohne auskam. ZWEI FEHLER unterwegs gefunden, beide aelter als dieser Umbau: 1. .btn stand in der Datei NACH .btn-save und .btn-ghost. Bei gleicher Spezifitaet gewinnt die spaetere Regel, also hat ihr `background: none` jeden Verlauf ueberschrieben -- der Speichern-Knopf sah aus wie ein Ghost-Knopf, schon in der gelben Fassung. Die Grundform steht jetzt vor allen Abwandlungen. 2. .nav-logo ist ein Flex-Container, und `gap` legte sich zwischen die Textknoten "D4RKST" und <span>3R</span>. In der Leiste stand "D4RKST 3R". Der Abstand haengt jetzt am Bild statt am Container. DREI ABWEICHUNGEN vom Entwurf, bewusst: - Die Statuszeile im Hero zeigt nur "ONLINE", nicht "38 MS · UPTIME 99,2 %". Antwortzeit und Erreichbarkeit des Bots liegen ausschliesslich hinter /api/settings und damit hinter der Anmeldung; oeffentlich gibt es sie nirgends. Dafuer braeuchte es einen neuen Endpunkt -- erfunden wird hier nichts. - Keine Aura am Hauptknopf: clip-path schneidet den Schatten des eigenen Elements mit weg. Im Referenz-HTML stehen box-shadow und clip-path am selben Element, dort ist sie also ebenso wenig zu sehen. Tote Deklaration weggelassen statt mitgeschleppt. - Die Config-Bereiche bleiben scharfkantig statt abgeschraegt. In ihnen klappen Auswahllisten aus dem Panel heraus, die clip-path abschneiden wuerde. Deckt sich mit der Vorgabe, dass die uebrigen Tabs nur die Token uebernehmen; die Dashboard-Panels selbst sind abgeschraegt. Dazu: .content von 860 auf 1160 px, weil die dreispaltigen Raster es brauchen -- die Devlog-Timeline behaelt 880 px Lesebreite, sonst waeren die Zeilen zu lang. Der Punkt an den Timeline-Karten ist entfallen: er sass ausserhalb der Karte und waere von der Abschraegung weggeschnitten worden. Welches Wort in einer Ueberschrift den Verlauf bekommt, steht jetzt im Woerterbuch -- der Teil in eckigen Klammern ("Funk[tionen]", aber "Fea[tures]"). So entscheidet jede Sprache selbst, wo sich das Wort teilen laesst. Mitgezogen: brand.js, das geteilte Design-System fuer die eingebundenen Dienste. Neue Tokens und Abschraegungen, aber --neon/--neon2 bleiben als Zweitname stehen -- fremde Apps benutzen sie in ihrem eigenen CSS. Marken-Dateien nach frontend/public/brand/, verkleinert auf die Groessen, in denen sie wirklich dargestellt werden: 2,4 MB -> 97 KB. Geprueft: Build laeuft. Im Browser durchgesehen -- Landing, Funktionen, Befehle, Hub-Start, Devlogs, Server, Roadmap, Dashboard. Ungeprueft: das Dashboard mit echter Anmeldung. Eine Sitzung war hier nicht moeglich, gesehen habe ich es ueber gestubbte API-Antworten. Ebenso ungeprueft, wie sich die Schriftumstellung auf schmalen Schirmen macht. NICHT angefasst: die Embed-Farben in runtime-settings.js stehen weiter auf dem alten Gelb -- die wirken in Discord, nicht auf der Webseite. Und ist brand_color in der Datenbank gesetzt, behalten die eingebundenen Dienste ihre bisherige Farbe, unabhaengig von den neuen Vorgabewerten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
10 KiB
React
203 lines
10 KiB
React
// 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 { Link } from 'react-router-dom';
|
|
import { apiGet, apiPost, apiDelete } from '../api.js';
|
|
import { IconCheck, IconMagic } from '../icons.jsx';
|
|
|
|
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);
|
|
const [wipeMsg, setWipeMsg] = useState('');
|
|
|
|
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 wipeData() {
|
|
if (!window.confirm(
|
|
'Wirklich alle gespeicherten Daten löschen?\n\n' +
|
|
'Betrifft: XP/Level, Geburtstag, Playtester-Eintrag, Abstimmungen, ' +
|
|
'Erinnerungen, Rollen-Sicherung und deinen Alpha-Key.\n\n' +
|
|
'Das lässt sich nicht rückgängig machen.'
|
|
)) return;
|
|
try {
|
|
const res = await apiDelete('/api/profile');
|
|
const total = Object.values(res.deleted).reduce((a, b) => a + b, 0);
|
|
setWipeMsg(`✓ ${total} Einträge gelöscht.`);
|
|
apiGet('/api/profile').then(setProfile).catch(() => {});
|
|
} catch {
|
|
setWipeMsg('✗ Löschen fehlgeschlagen — melde dich bitte beim Team.');
|
|
}
|
|
}
|
|
|
|
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-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 with-icon"><IconMagic size={14} /> 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 ? <span className="with-icon"><IconCheck size={13} /> Kopiert</span> : '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>
|
|
)}
|
|
|
|
{/* DSGVO Art. 17: eigene Daten löschen */}
|
|
<div className="roadmap-group danger-zone">
|
|
<h2 className="settings-title">Meine Daten</h2>
|
|
<p className="field-hint">
|
|
Du kannst jederzeit alle Daten löschen, die hier zu dir gespeichert
|
|
sind: XP/Level, Geburtstag, Playtester-Eintrag, Abstimmungen,
|
|
Erinnerungen, Rollen-Sicherung und einen zugeteilten Alpha-Key.
|
|
Was du im Discord gepostet hast, löschst du bitte dort. Details im{' '}
|
|
<Link to="/datenschutz" style={{ textDecoration: 'underline' }}>Datenschutz</Link>.
|
|
</p>
|
|
<div className="field-row" style={{ marginTop: '.8rem' }}>
|
|
<button className="btn btn-danger" onClick={wipeData}>Meine Daten löschen</button>
|
|
</div>
|
|
{wipeMsg && <p className="notice" style={{ padding: '.6rem 0 0' }}>{wipeMsg}</p>}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
}
|