Webinterface: Emojis durch Lucide-Icons ersetzt
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Emojis werden vom Betriebssystem gerendert — auf Windows, Mac und Android
sehen sie unterschiedlich aus, und sie sind bunt. Im streng zweifarbigen
D4RKST3R-Look stachen sie entsprechend heraus.

Jetzt kommen die Symbole aus einem einzigen Set (Lucide, dünne Outlines),
zentral in src/icons.jsx gebündelt: eine Größe, eine Strichstärke, überall
gleich — und sie erben die Textfarbe, sind also Teil der Marke statt
Fremdkörper. Betroffen sind Navigation, Startseite, Server, Roadmap,
Profil, Level, Galerie, Devlogs, Lightbox, Composer und alle Setup-Tabs.

Bewusst Emojis geblieben sind:
- die Spiel-Presets der Server (landen als Icon im Discord-Embed)
- Rollen-Menü-Emojis und Platzhalter für Discord-Inhalte
- beschreibende Hinweise wie „🔔-Button am Post" oder „🟢/🔴 im Embed",
  die zeigen, wie etwas in Discord aussehen wird
- die selbst gepflegten Dienst-Icons im Portal

Dazu Detailarbeit: Server-Status als farbiger Punkt statt Emoji, Platz 1–3
der Bestenliste als Pokal in Gold/Silber/Bronze, Pagination mit Chevrons.
Bundle wächst um 19 kB (Tree-Shaking greift, nur die ~70 genutzten Icons).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:37:25 +02:00
co-authored by Claude Fable 5
parent 6daa1f7d43
commit 1afbe9d238
20 changed files with 295 additions and 79 deletions
+7 -5
View File
@@ -15,6 +15,7 @@ import Home from './pages/Home.jsx';
import DevlogDetail from './pages/DevlogDetail.jsx';
import Impressum from './pages/Impressum.jsx';
import Datenschutz from './pages/Datenschutz.jsx';
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
const COMMUNITY_LINKS = [
['/galerie', 'Galerie'],
@@ -35,7 +36,7 @@ function NavDropdown({ label, active, children }) {
return (
<div className={`nav-drop ${open ? 'open' : ''}`} ref={ref}>
<button className={`nav-drop-btn ${active ? 'active' : ''}`} onClick={() => setOpen((o) => !o)}>
{label} <span className="nav-caret"></span>
{label} <IconChevronDown size={13} className="nav-caret" />
</button>
{open && <div className="nav-drop-menu" onClick={() => setOpen(false)}>{children}</div>}
</div>
@@ -151,7 +152,7 @@ export default function App() {
</div>
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
{drawer ? '✕' : '☰'}
{drawer ? <IconX size={20} /> : <IconMenu size={20} />}
</button>
</header>
@@ -175,8 +176,9 @@ export default function App() {
<main className="main">
{gated && (
<div className="gate-banner">
<span>
🚪 Der Login ist Mitgliedern des Discord-Servers vorbehalten
<span className="with-icon">
<IconLock />
Der Login ist Mitgliedern des Discord-Servers vorbehalten
tritt erst dem Server bei und logg dich dann nochmal ein.
</span>
{invite && (
@@ -184,7 +186,7 @@ export default function App() {
Zum Discord
</a>
)}
<button className="card-delete" onClick={() => setGated(false)} title="Ausblenden"></button>
<button className="card-delete" onClick={() => setGated(false)} title="Ausblenden"><IconX size={15} /></button>
</div>
)}
<Routes>
+4 -3
View File
@@ -2,6 +2,7 @@
// mit Live-Vorschau im Discord-Look + speicherbaren Vorlagen.
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { IconBot, IconSave, IconX } from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
const EMPTY_EMBED = {
@@ -54,7 +55,7 @@ function DiscordPreview({ content, embed, useEmbed }) {
return (
<div className="dpv">
<div className="dpv-msg">
<div className="dpv-avatar">🤖</div>
<div className="dpv-avatar"><IconBot size={20} /></div>
<div className="dpv-body">
<div className="dpv-name">d4rkbot <span className="dpv-tag">APP</span> <span className="dpv-time">heute um {new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span></div>
{content.trim() && <div className="dpv-content"><Markdown text={content} /></div>}
@@ -179,8 +180,8 @@ export default function EmbedComposer({ channelOptions, flash }) {
{templates.map((t) => <option key={t.name} value={t.name}>{t.name}</option>)}
</select>
<input type="text" style={{ flex: 1 }} placeholder="Als Vorlage speichern unter…" value={templateName} onChange={(e) => setTemplateName(e.target.value)} />
<button className="btn" onClick={saveTemplate}>💾</button>
<button className="card-delete" title="Vorlage löschen" onClick={removeTemplate}></button>
<button className="btn" onClick={saveTemplate} title="Vorlage speichern"><IconSave size={15} /></button>
<button className="card-delete" title="Vorlage löschen" onClick={removeTemplate}><IconX size={14} /></button>
</div>
</div>
+4 -3
View File
@@ -3,6 +3,7 @@
// die Pfeile stehen NEBEN dem Bild, die Info-Zeile darunter.
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { IconX, IconChevronLeft, IconChevronRight } from '../icons.jsx';
export default function Lightbox({ images, start = 0, onClose }) {
const [index, setIndex] = useState(start);
@@ -42,10 +43,10 @@ export default function Lightbox({ images, start = 0, onClose }) {
}}
role="dialog" aria-modal="true"
>
<button className="lb-close" onClick={onClose} title="Schließen (Esc)"></button>
<button className="lb-close" onClick={onClose} title="Schließen (Esc)"><IconX size={17} /></button>
<div className="lb-row">
{images.length > 1 && (
<button className="lb-nav" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück (←)"></button>
<button className="lb-nav" onClick={(e) => { e.stopPropagation(); prev(); }} title="Zurück"><IconChevronLeft size={24} /></button>
)}
<figure className="lb-figure" onClick={(e) => e.stopPropagation()}>
<img className="lb-img" src={img.src ?? img} alt={img.alt ?? ''} />
@@ -55,7 +56,7 @@ export default function Lightbox({ images, start = 0, onClose }) {
</figcaption>
</figure>
{images.length > 1 && (
<button className="lb-nav" onClick={(e) => { e.stopPropagation(); next(); }} title="Weiter (→)"></button>
<button className="lb-nav" onClick={(e) => { e.stopPropagation(); next(); }} title="Weiter"><IconChevronRight size={24} /></button>
)}
</div>
</div>,
+105
View File
@@ -0,0 +1,105 @@
// Zentrale Icon-Sammlung (Lucide) — ein Set, eine Strichstärke, überall gleich.
//
// Bewusst NICHT ersetzt werden Emojis, die im Discord landen oder von Nutzern
// kommen: Spiel-Icons der Server-Presets, Rollen-Menü-Emojis, Embed-Inhalte.
// Discord kennt keine SVGs — dort sind Emojis die richtige Wahl.
import {
Activity, AlertTriangle, Archive, ArrowLeft, ArrowRight, Award, Bell, Bot,
Cake, Calendar, Camera, Check, ChevronDown, ChevronLeft, ChevronRight,
Clock, Copy, Database, Download, Ticket, FileText, Gamepad2, Gauge, Gift,
Globe, Hash, Heart, Image, Key, Layers, Link2, List, Lock, LogOut, Mail,
MapPin, Megaphone, MessageSquare, Mic, Monitor, Moon, Package, Palette, UserCog,
PartyPopper, Pencil, Play, Plus, RefreshCw, Rocket, Save, Search, Send,
Server, Settings, Shield, Sparkles, Star, Tag, Terminal, ThumbsUp, Trash2,
TrendingUp, Trophy, Upload, User, Users, Wand2, Wrench, X, Zap,
} from 'lucide-react';
/** Einheitliche Darstellung: 18px, dünne Linien, am Text ausgerichtet */
const defaults = { size: 18, strokeWidth: 1.75, 'aria-hidden': true };
const make = (Cmp) => function Icon(props) {
return <Cmp {...defaults} {...props} className={`ic ${props.className ?? ''}`} />;
};
/* ── Bereiche & Navigation ──────────────────────────── */
export const IconDevlog = make(FileText);
export const IconRoadmap = make(MapPin);
export const IconMapPin = make(MapPin);
export const IconServer = make(Server);
export const IconGallery = make(Image);
export const IconLevel = make(TrendingUp);
export const IconEvents = make(Calendar);
export const IconChangelog = make(Rocket);
export const IconCommits = make(Terminal);
export const IconSetup = make(Settings);
export const IconProfile = make(User);
export const IconHome = make(Layers);
export const IconLogout = make(LogOut);
export const IconMenu = make(List);
/* ── Setup-Tabs ─────────────────────────────────────── */
export const IconStatus = make(Gauge);
export const IconBrand = make(Palette);
export const IconFeeds = make(Megaphone);
export const IconCommunity = make(Users);
export const IconRoles = make(Shield);
export const IconSupport = make(Ticket);
export const IconApplications = make(FileText);
export const IconComposer = make(Pencil);
export const IconSystem = make(Wrench);
export const IconApi = make(Key);
export const IconTeam = make(UserCog);
/* ── Aktionen & Zustände ────────────────────────────── */
export const IconCheck = make(Check);
export const IconX = make(X);
export const IconPlus = make(Plus);
export const IconTrash = make(Trash2);
export const IconEdit = make(Pencil);
export const IconSave = make(Save);
export const IconSend = make(Send);
export const IconCopy = make(Copy);
export const IconSearch = make(Search);
export const IconRefresh = make(RefreshCw);
export const IconUpload = make(Upload);
export const IconDownload = make(Download);
export const IconLink = make(Link2);
export const IconPlay = make(Play);
export const IconWarning = make(AlertTriangle);
export const IconLock = make(Lock);
export const IconArrowLeft = make(ArrowLeft);
export const IconArrowRight = make(ArrowRight);
export const IconChevronDown = make(ChevronDown);
export const IconChevronLeft = make(ChevronLeft);
export const IconChevronRight = make(ChevronRight);
/* ── Inhalte & Features ─────────────────────────────── */
export const IconBot = make(Bot);
export const IconBell = make(Bell);
export const IconBirthday = make(Cake);
export const IconGiveaway = make(Gift);
export const IconStar = make(Star);
export const IconTag = make(Tag);
export const IconKey = make(Key);
export const IconTrophy = make(Trophy);
export const IconAward = make(Award);
export const IconThumbsUp = make(ThumbsUp);
export const IconIdea = make(Sparkles);
export const IconMessage = make(MessageSquare);
export const IconMail = make(Mail);
export const IconVoice = make(Mic);
export const IconGame = make(Gamepad2);
export const IconMonitor = make(Monitor);
export const IconDatabase = make(Database);
export const IconArchive = make(Archive);
export const IconPackage = make(Package);
export const IconGlobe = make(Globe);
export const IconChannel = make(Hash);
export const IconClock = make(Clock);
export const IconActivity = make(Activity);
export const IconCamera = make(Camera);
export const IconHeart = make(Heart);
export const IconParty = make(PartyPopper);
export const IconMagic = make(Wand2);
export const IconZap = make(Zap);
export const IconMoon = make(Moon);
+4 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { Markdown } from '../markdown.jsx';
@@ -52,7 +53,7 @@ export default function Changelog() {
</div>
{r.url && (
<div className="card-footer">
<a href={r.url} target="_blank" rel="noreferrer">Release in Gitea ansehen </a>
<a href={r.url} target="_blank" rel="noreferrer">Release in Gitea ansehen <IconChevronRight size={13} /></a>
</div>
)}
</article>
@@ -61,13 +62,13 @@ export default function Changelog() {
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
<IconChevronLeft size={14} /> Neuere
</button>
<span className="pager-info">
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
Ältere <IconChevronRight size={14} />
</button>
</div>
)}
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
@@ -90,13 +91,13 @@ export default function Commits({ me }) {
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
<IconChevronLeft size={14} /> Neuere
</button>
<span className="pager-info">
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
Ältere <IconChevronRight size={14} />
</button>
</div>
)}
+3 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { apiGet } from '../api.js';
import { IconChevronLeft, IconCheck, IconLink } from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
@@ -79,8 +80,8 @@ export default function DevlogDetail() {
</article>
<div className="field-row" style={{ marginTop: '1.2rem' }}>
<Link className="btn" to="/devlogs"> Alle Devlogs</Link>
<button className="btn" onClick={copyLink}>{copied ? '✓ Link kopiert' : '🔗 Link kopieren'}</button>
<Link className="btn with-icon" to="/devlogs"><IconChevronLeft size={14} /> Alle Devlogs</Link>
<button className="btn" onClick={copyLink}>{copied ? <span className="with-icon"><IconCheck size={14} /> Link kopiert</span> : <span className="with-icon"><IconLink size={14} /> Link kopieren</span>}</button>
</div>
{lightbox !== null && (
+6 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGet, apiDelete } from '../api.js';
import { IconChevronLeft, IconChevronRight, IconSearch, IconLink, IconX } from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
@@ -102,7 +103,7 @@ export default function Devlogs({ me }) {
<section className="content">
<div className="search">
<span className="search-icon"></span>
<span className="search-icon"><IconSearch size={16} /></span>
<input
type="search"
placeholder="Devlogs durchsuchen …"
@@ -135,14 +136,14 @@ export default function Devlogs({ me }) {
</span>
{project && <span className="card-project">{project}</span>}
<span className="card-rel">{relative(d.posted_at)}</span>
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog">🔗</a>
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog"><IconLink size={14} /></a>
{me?.admin && (
<button
className="card-delete"
title="Aus dem Archiv löschen"
onClick={() => remove(d.message_id)}
>
<IconX size={15} />
</button>
)}
</div>
@@ -187,13 +188,13 @@ export default function Devlogs({ me }) {
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
<IconChevronLeft size={14} /> Neuere
</button>
<span className="pager-info">
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
Ältere <IconChevronRight size={14} />
</button>
</div>
)}
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { IconStar, IconChevronRight } from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
@@ -37,7 +38,7 @@ export default function Events() {
{e.cover && <a href={e.url} target="_blank" rel="noreferrer"><img className="event-cover" src={e.cover} alt="" /></a>}
<div className="card-meta">
<span className="card-date">{e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'}</span>
{e.interested != null && <span className="card-rel"> {e.interested} interessiert</span>}
{e.interested != null && <span className="card-rel with-icon"><IconStar size={13} /> {e.interested} interessiert</span>}
</div>
<h3 className="release-name">{e.name}</h3>
{e.description && (
@@ -46,7 +47,7 @@ export default function Events() {
</div>
)}
<div className="card-footer">
<a href={e.url} target="_blank" rel="noreferrer">Im Discord ansehen </a>
<a href={e.url} target="_blank" rel="noreferrer">Im Discord ansehen <IconChevronRight size={13} /></a>
</div>
</article>
))}
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
import Lightbox from '../components/Lightbox.jsx';
import { SkeletonGrid } from '../components/Skeleton.jsx';
@@ -68,13 +69,13 @@ export default function Galerie() {
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
<IconChevronLeft size={14} /> Neuere
</button>
<span className="pager-info">
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
Ältere <IconChevronRight size={14} />
</button>
</div>
)}
+26 -13
View File
@@ -4,14 +4,18 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
import {
IconDevlog, IconRoadmap, IconServer, IconGallery, IconLevel, IconEvents,
IconMessage, IconArrowRight, IconLink,
} from '../icons.jsx';
const TILES = [
['/devlogs', '📔', 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
['/roadmap', '🗺️', 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
['/server', '🎮', 'Server', 'Live-Status aller Game-Server mit Verlauf'],
['/galerie', '🖼️', 'Galerie', 'Screenshots aus der Community'],
['/level', '📈', 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
['/events', '📅', 'Events', 'Playtests, Streams und was sonst ansteht'],
['/devlogs', IconDevlog, 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
['/roadmap', IconRoadmap, 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
['/server', IconServer, 'Server', 'Live-Status aller Game-Server mit Verlauf'],
['/galerie', IconGallery, 'Galerie', 'Screenshots aus der Community'],
['/level', IconLevel, 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
['/events', IconEvents, 'Events', 'Playtests, Streams und was sonst ansteht'],
];
/** Devlog-Text für den Teaser kürzen (erste Sätze, ohne Titel-Zeile) */
@@ -57,8 +61,14 @@ export default function Home() {
an einem Ort, direkt aus dem Discord.
</p>
<div className="home-cta">
{invite && <a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">💬 Zum Discord</a>}
<Link className="btn btn-ghost home-cta-alt" to="/devlogs">📔 Devlogs lesen</Link>
{invite && (
<a className="btn btn-save with-icon" href={invite} target="_blank" rel="noreferrer">
<IconMessage /> Zum Discord
</a>
)}
<Link className="btn btn-ghost home-cta-alt with-icon" to="/devlogs">
<IconDevlog /> Devlogs lesen
</Link>
</div>
</div>
</section>
@@ -88,9 +98,9 @@ export default function Home() {
)}
<div className="home-tiles">
{TILES.map(([to, icon, title, desc]) => (
{TILES.map(([to, Icon, title, desc]) => (
<Link className="card home-tile" to={to} key={to}>
<span className="home-tile-icon">{icon}</span>
<span className="home-tile-icon"><Icon size={26} /></span>
<span className="home-tile-title">{title}</span>
<span className="home-tile-desc">{desc}</span>
</Link>
@@ -101,10 +111,13 @@ export default function Home() {
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Dienste</h2>
<div className="home-tiles">
{/* Dienst-Icons pflegt der Owner selbst — daher bleiben es Emojis */}
{services.map((s) => (
<a className="card home-tile" href={s.url} target="_blank" rel="noreferrer" key={s.id}>
<span className="home-tile-icon">{s.icon}</span>
<span className="home-tile-title">{s.name} <span className="home-tile-ext"></span></span>
<span className="home-tile-icon home-tile-emoji">{s.icon}</span>
<span className="home-tile-title with-icon">
{s.name} <IconLink size={14} className="home-tile-ext" />
</span>
{s.description && <span className="home-tile-desc">{s.description}</span>}
</a>
))}
@@ -119,7 +132,7 @@ export default function Home() {
<div className="card-body">
<Markdown text={teaser(latest.content)} />
</div>
<span className="home-devlog-more">Weiterlesen </span>
<span className="home-devlog-more with-icon">Weiterlesen <IconArrowRight size={14} /></span>
</Link>
</div>
)}
+1 -1
View File
@@ -29,7 +29,7 @@ export default function Impressum() {
<section className="content legal-text">
{incomplete && (
<p className="notice">
Die Impressums-Angaben sind noch nicht vollständig hinterlegt
Die Impressums-Angaben sind noch nicht vollständig hinterlegt
(Setup System Impressum).
</p>
)}
+9 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { SkeletonRows } from '../components/Skeleton.jsx';
import { IconTrophy } from '../icons.jsx';
/** Muss der Formel in src/bot/levels.js entsprechen */
function xpForLevel(level) {
@@ -9,7 +10,8 @@ function xpForLevel(level) {
return total;
}
const MEDALS = ['🥇', '🥈', '🥉'];
// Platz 13 bekommen einen Pokal in Gold/Silber/Bronze, danach schlicht die Nummer
const MEDAL_COLORS = ['#f5c518', '#c0c4cc', '#c07a3e'];
/** Nachrichten/Tag der letzten 30 Tage als Balken */
function ActivityChart({ stats }) {
@@ -66,7 +68,7 @@ export default function Level() {
<section className="content">
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
{!error && !rows && <SkeletonRows n={10} />}
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord!</p>}
{rows?.length > 0 && (
<div className="lvl-list">
@@ -76,7 +78,11 @@ export default function Level() {
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
return (
<div className="lvl-row" key={r.user_id}>
<span className="lvl-rank">{MEDALS[i] ?? `#${i + 1}`}</span>
<span className="lvl-rank">
{i < 3
? <IconTrophy size={18} style={{ color: MEDAL_COLORS[i] }} />
: `#${i + 1}`}
</span>
<span className="lvl-name">{r.username ?? r.user_id}</span>
<div className="lvl-progress">
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
+10 -5
View File
@@ -3,6 +3,7 @@
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 '—';
@@ -52,10 +53,10 @@ export default function Profil({ me }) {
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.`);
setWipeMsg(` ${total} Einträge gelöscht.`);
apiGet('/api/profile').then(setProfile).catch(() => {});
} catch {
setWipeMsg('⚠️ Löschen fehlgeschlagen — melde dich bitte beim Team.');
setWipeMsg(' Löschen fehlgeschlagen — melde dich bitte beim Team.');
}
}
@@ -96,7 +97,7 @@ export default function Profil({ me }) {
</section>
<section className="content">
{error && <p className="notice"> {error}</p>}
{error && <p className="notice"> {error}</p>}
{!profile && !error && <p className="notice">Lade </p>}
{profile && (
@@ -108,7 +109,9 @@ export default function Profil({ me }) {
<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>}
{profile.playtester && (
<span className="profile-badge with-icon"><IconMagic size={14} /> Playtester</span>
)}
</p>
{profile.roles.length > 0 && (
<div className="profile-roles">
@@ -145,7 +148,9 @@ export default function Profil({ me }) {
<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>
<button className="btn btn-mini" onClick={copyKey}>
{copied ? <span className="with-icon"><IconCheck size={13} /> Kopiert</span> : 'Kopieren'}
</button>
</div>
</div>
)}
+8 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { apiGet, apiPost } from '../api.js';
import { Markdown } from '../markdown.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconThumbsUp } from '../icons.jsx';
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
@@ -104,20 +105,20 @@ export default function Roadmap() {
? 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.');
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; }
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!');
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.');
setSubmitMsg(e.status === 403 ? ' Einreichen dürfen nur Discord-Member.' : ' Konnte den Wunsch nicht einreichen.');
}
}
@@ -151,14 +152,14 @@ export default function Roadmap() {
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
{canVote && w.message_id ? (
<button
className={`wish-vote${w.voted ? ' voted' : ''}`}
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
onClick={() => vote(w.message_id)}
>
👍 {w.score}
<IconThumbsUp size={14} /> {w.score}
</button>
) : (
<span className="wish-score">👍 {w.score}</span>
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
)}
<span className="wish-idea">{w.idea}</span>
<span className="wish-author">{w.author}</span>
+13 -4
View File
@@ -4,6 +4,7 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconGame, IconMapPin, IconClock, IconCheck } from '../icons.jsx';
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
@@ -68,9 +69,13 @@ function ServerCard({ s }) {
<div className="srv-title">
<span className="srv-name">{s.name}</span>
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
{s.online === null ? '⏳ Warte auf Check' : s.online ? '🟢 Online' : '🔴 Offline'}
{s.online === null
? <><IconClock size={13} /> Warte auf Check</>
: <><span className="srv-dot" /> {s.online ? 'Online' : 'Offline'}</>}
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
{s.map && s.online && <span className="srv-map"> · 🗺 {s.map}</span>}
{s.map && s.online && (
<span className="srv-map"> · <IconMapPin size={13} /> {s.map}</span>
)}
</span>
</div>
{s.online && s.players != null && (
@@ -102,10 +107,14 @@ function ServerCard({ s }) {
{(s.connect_url || s.address) && (
<div className="srv-connect">
{s.connect_url && <a className="btn btn-save" href={s.connect_url}>🎮 Connect</a>}
{s.connect_url && (
<a className="btn btn-save with-icon" href={s.connect_url}>
<IconGame size={15} /> Connect
</a>
)}
{s.address && (
<button className="alpha-key srv-addr" onClick={copyAddress} title="Adresse kopieren">
{copied ? '✓ Kopiert!' : s.address}
{copied ? <span className="with-icon"><IconCheck size={14} /> Kopiert!</span> : s.address}
</button>
)}
</div>
+24 -19
View File
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
import EmbedComposer from '../components/EmbedComposer.jsx';
import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey,
} from '../icons.jsx';
const API_SCOPES = [
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
@@ -10,18 +15,18 @@ const API_SCOPES = [
];
const TABS = [
{ id: 'status', icon: '📊', label: 'Status' },
{ id: 'brand', icon: '🎨', label: 'Brand' },
{ id: 'feeds', icon: '📔', label: 'Feeds' },
{ id: 'community', icon: '🙌', label: 'Community' },
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
{ id: 'support', icon: '📬', label: 'Support' },
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
{ id: 'composer', icon: '📝', label: 'Composer' },
{ id: 'server', icon: '🎮', label: 'Server' },
{ id: 'system', icon: '⚙️', label: 'System' },
{ id: 'api', icon: '🔑', label: 'API' },
{ id: 'team', icon: '👥', label: 'Team' },
{ id: 'status', Icon: IconStatus, label: 'Status' },
{ id: 'brand', Icon: IconBrand, label: 'Brand' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
{ id: 'community', Icon: IconCommunity, label: 'Community' },
{ id: 'rollen', Icon: IconRoles, label: 'Rollen' },
{ id: 'support', Icon: IconSupport, label: 'Support' },
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen' },
{ id: 'composer', Icon: IconComposer, label: 'Composer' },
{ id: 'server', Icon: IconServer, label: 'Server' },
{ id: 'system', Icon: IconSystem, label: 'System' },
{ id: 'api', Icon: IconApi, label: 'API' },
{ id: 'team', Icon: IconTeam, label: 'Team' },
];
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
@@ -574,7 +579,7 @@ export default function Settings({ me }) {
{header}
<section className="content">
<div className="notice center">
<p>🔒 Nur für den Admin.</p>
<p className="with-icon"><IconLock /> Nur für den Admin.</p>
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
</div>
</section>
@@ -622,15 +627,15 @@ export default function Settings({ me }) {
<div className="bot-avatar-wrap">
{data.status.botAvatar
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
: <div className="bot-avatar bot-avatar-empty">🤖</div>}
: <div className="bot-avatar bot-avatar-empty"><IconBot size={30} /></div>}
<span className="bot-dot" style={{ background: statusDot }} />
</div>
<label className="btn btn-upload">
Bild ändern
<IconUpload size={14} /> Bild ändern
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
</label>
<label className="btn btn-upload btn-ghost">
🖼 Banner
<IconUpload size={14} /> Banner
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
</label>
</div>
@@ -844,7 +849,7 @@ export default function Settings({ me }) {
onClick={distributeKeys}
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
>
🎟️ An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
<IconKey size={14} /> An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
</button>
</div>
{alphaKeys?.assigned?.length > 0 && (
@@ -937,7 +942,7 @@ export default function Settings({ me }) {
<div className="settings-section">
<h2 className="settings-title">// Rollen-Menüs</h2>
<p className="section-intro">
Selbstbedienungs-Rollen wie bei Carl-bot nur ohne Paywall. 😄 Der Bot postet
Selbstbedienungs-Rollen wie bei Carl-bot nur ohne Paywall. Der Bot postet
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
jederzeit editierbar, der Discord-Post zieht automatisch mit.
</p>
@@ -1712,7 +1717,7 @@ export default function Settings({ me }) {
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
<span className="settings-nav-icon">{t.icon}</span>
<span className="settings-nav-icon"><t.Icon /></span>
{t.label}
</button>
))}
+51
View File
@@ -1512,3 +1512,54 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.btn-danger { border-color: rgba(242, 63, 67, .5); color: #f23f43; }
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
.home-tile-ext { font-size: .8rem; color: var(--muted2); }
/* ── ICONS (Lucide) ────────────────────────────────── */
/* Einheitliche Ausrichtung: Icons sitzen auf der Textlinie, erben die Farbe */
.ic {
display: inline-block;
vertical-align: -0.18em;
flex-shrink: 0;
}
/* Text + Icon nebeneinander, ohne Zeilenumbruch dazwischen */
.with-icon {
display: inline-flex;
align-items: center;
gap: .45rem;
}
button.with-icon, a.with-icon { justify-content: center; }
/* Nav-Pfeil des Dropdowns */
.nav-caret { transition: transform .2s; }
.nav-drop.open .nav-caret { transform: rotate(180deg); }
/* Setup-Sidebar: Icons in Markenfarbe, aktiver Tab hebt sich ab */
.settings-nav-icon { display: inline-flex; color: var(--muted); transition: color .2s; }
.settings-nav-item:hover .settings-nav-icon { color: var(--text); }
.settings-nav-item.active .settings-nav-icon { color: var(--neon); }
/* Kachel-Icons auf der Startseite */
.home-tile-icon { color: var(--neon); display: inline-flex; }
.home-tile:hover .home-tile-icon { color: var(--neon2); }
.home-tile-emoji { font-size: 1.6rem; line-height: 1; }
.home-tile-ext { color: var(--muted2); }
/* Server-Status: Punkt statt Emoji */
.srv-status { flex-wrap: wrap; row-gap: .1rem; }
.srv-status > span { white-space: nowrap; }
.srv-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted2); display: inline-block; flex-shrink: 0;
}
.srv-status.on .srv-dot { background: var(--success); box-shadow: 0 0 8px #00ff8866; }
.srv-status.off .srv-dot { background: #f23f43; box-shadow: 0 0 8px #f23f4366; }
.srv-status { display: inline-flex; align-items: center; gap: .4rem; }
.srv-map, .srv-ping { display: inline-flex; align-items: center; gap: .3rem; }
/* Bestenliste: Pokal statt Medaillen-Emoji */
.lvl-rank { display: inline-flex; align-items: center; justify-content: center; }
/* Bot-Avatar-Platzhalter */
.bot-avatar-empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }
/* Discord-Vorschau im Composer */
.dpv-avatar { display: flex; align-items: center; justify-content: center; color: var(--muted); }