Webinterface: Emojis durch Lucide-Icons ersetzt
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:
Generated
+10
@@ -11,6 +11,7 @@
|
|||||||
"@fontsource/barlow-condensed": "^5.3.0",
|
"@fontsource/barlow-condensed": "^5.3.0",
|
||||||
"@fontsource/bebas-neue": "^5.3.0",
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
"@fontsource/share-tech-mono": "^5.3.0",
|
"@fontsource/share-tech-mono": "^5.3.0",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0"
|
"react-router-dom": "^7.1.0"
|
||||||
@@ -1541,6 +1542,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||||
|
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@fontsource/barlow-condensed": "^5.3.0",
|
"@fontsource/barlow-condensed": "^5.3.0",
|
||||||
"@fontsource/bebas-neue": "^5.3.0",
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
"@fontsource/share-tech-mono": "^5.3.0",
|
"@fontsource/share-tech-mono": "^5.3.0",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0"
|
"react-router-dom": "^7.1.0"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Home from './pages/Home.jsx';
|
|||||||
import DevlogDetail from './pages/DevlogDetail.jsx';
|
import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||||
import Impressum from './pages/Impressum.jsx';
|
import Impressum from './pages/Impressum.jsx';
|
||||||
import Datenschutz from './pages/Datenschutz.jsx';
|
import Datenschutz from './pages/Datenschutz.jsx';
|
||||||
|
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
|
||||||
|
|
||||||
const COMMUNITY_LINKS = [
|
const COMMUNITY_LINKS = [
|
||||||
['/galerie', 'Galerie'],
|
['/galerie', 'Galerie'],
|
||||||
@@ -35,7 +36,7 @@ function NavDropdown({ label, active, children }) {
|
|||||||
return (
|
return (
|
||||||
<div className={`nav-drop ${open ? 'open' : ''}`} ref={ref}>
|
<div className={`nav-drop ${open ? 'open' : ''}`} ref={ref}>
|
||||||
<button className={`nav-drop-btn ${active ? 'active' : ''}`} onClick={() => setOpen((o) => !o)}>
|
<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>
|
</button>
|
||||||
{open && <div className="nav-drop-menu" onClick={() => setOpen(false)}>{children}</div>}
|
{open && <div className="nav-drop-menu" onClick={() => setOpen(false)}>{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +152,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
|
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
|
||||||
{drawer ? '✕' : '☰'}
|
{drawer ? <IconX size={20} /> : <IconMenu size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -175,8 +176,9 @@ export default function App() {
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
{gated && (
|
{gated && (
|
||||||
<div className="gate-banner">
|
<div className="gate-banner">
|
||||||
<span>
|
<span className="with-icon">
|
||||||
🚪 Der Login ist Mitgliedern des Discord-Servers vorbehalten —
|
<IconLock />
|
||||||
|
Der Login ist Mitgliedern des Discord-Servers vorbehalten —
|
||||||
tritt erst dem Server bei und logg dich dann nochmal ein.
|
tritt erst dem Server bei und logg dich dann nochmal ein.
|
||||||
</span>
|
</span>
|
||||||
{invite && (
|
{invite && (
|
||||||
@@ -184,7 +186,7 @@ export default function App() {
|
|||||||
Zum Discord
|
Zum Discord
|
||||||
</a>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// mit Live-Vorschau im Discord-Look + speicherbaren Vorlagen.
|
// mit Live-Vorschau im Discord-Look + speicherbaren Vorlagen.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||||
|
import { IconBot, IconSave, IconX } from '../icons.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
const EMPTY_EMBED = {
|
const EMPTY_EMBED = {
|
||||||
@@ -54,7 +55,7 @@ function DiscordPreview({ content, embed, useEmbed }) {
|
|||||||
return (
|
return (
|
||||||
<div className="dpv">
|
<div className="dpv">
|
||||||
<div className="dpv-msg">
|
<div className="dpv-msg">
|
||||||
<div className="dpv-avatar">🤖</div>
|
<div className="dpv-avatar"><IconBot size={20} /></div>
|
||||||
<div className="dpv-body">
|
<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>
|
<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>}
|
{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>)}
|
{templates.map((t) => <option key={t.name} value={t.name}>{t.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="text" style={{ flex: 1 }} placeholder="Als Vorlage speichern unter…" value={templateName} onChange={(e) => setTemplateName(e.target.value)} />
|
<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="btn" onClick={saveTemplate} title="Vorlage speichern"><IconSave size={15} /></button>
|
||||||
<button className="card-delete" title="Vorlage löschen" onClick={removeTemplate}>✕</button>
|
<button className="card-delete" title="Vorlage löschen" onClick={removeTemplate}><IconX size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// die Pfeile stehen NEBEN dem Bild, die Info-Zeile darunter.
|
// die Pfeile stehen NEBEN dem Bild, die Info-Zeile darunter.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconX, IconChevronLeft, IconChevronRight } from '../icons.jsx';
|
||||||
|
|
||||||
export default function Lightbox({ images, start = 0, onClose }) {
|
export default function Lightbox({ images, start = 0, onClose }) {
|
||||||
const [index, setIndex] = useState(start);
|
const [index, setIndex] = useState(start);
|
||||||
@@ -42,10 +43,10 @@ export default function Lightbox({ images, start = 0, onClose }) {
|
|||||||
}}
|
}}
|
||||||
role="dialog" aria-modal="true"
|
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">
|
<div className="lb-row">
|
||||||
{images.length > 1 && (
|
{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()}>
|
<figure className="lb-figure" onClick={(e) => e.stopPropagation()}>
|
||||||
<img className="lb-img" src={img.src ?? img} alt={img.alt ?? ''} />
|
<img className="lb-img" src={img.src ?? img} alt={img.alt ?? ''} />
|
||||||
@@ -55,7 +56,7 @@ export default function Lightbox({ images, start = 0, onClose }) {
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
{images.length > 1 && (
|
{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>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export default function Changelog() {
|
|||||||
</div>
|
</div>
|
||||||
{r.url && (
|
{r.url && (
|
||||||
<div className="card-footer">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -61,13 +62,13 @@ export default function Changelog() {
|
|||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
← Neuere
|
<IconChevronLeft size={14} /> Neuere
|
||||||
</button>
|
</button>
|
||||||
<span className="pager-info">
|
<span className="pager-info">
|
||||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
Ältere →
|
Ältere <IconChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
@@ -90,13 +91,13 @@ export default function Commits({ me }) {
|
|||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
← Neuere
|
<IconChevronLeft size={14} /> Neuere
|
||||||
</button>
|
</button>
|
||||||
<span className="pager-info">
|
<span className="pager-info">
|
||||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
Ältere →
|
Ältere <IconChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { IconChevronLeft, IconCheck, IconLink } from '../icons.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
import Lightbox from '../components/Lightbox.jsx';
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
@@ -79,8 +80,8 @@ export default function DevlogDetail() {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div className="field-row" style={{ marginTop: '1.2rem' }}>
|
<div className="field-row" style={{ marginTop: '1.2rem' }}>
|
||||||
<Link className="btn" to="/devlogs">← Alle Devlogs</Link>
|
<Link className="btn with-icon" to="/devlogs"><IconChevronLeft size={14} /> Alle Devlogs</Link>
|
||||||
<button className="btn" onClick={copyLink}>{copied ? '✓ Link kopiert' : '🔗 Link kopieren'}</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{lightbox !== null && (
|
{lightbox !== null && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { apiGet, apiDelete } from '../api.js';
|
import { apiGet, apiDelete } from '../api.js';
|
||||||
|
import { IconChevronLeft, IconChevronRight, IconSearch, IconLink, IconX } from '../icons.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
import Lightbox from '../components/Lightbox.jsx';
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
@@ -102,7 +103,7 @@ export default function Devlogs({ me }) {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
<div className="search">
|
<div className="search">
|
||||||
<span className="search-icon">⌕</span>
|
<span className="search-icon"><IconSearch size={16} /></span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Devlogs durchsuchen …"
|
placeholder="Devlogs durchsuchen …"
|
||||||
@@ -135,14 +136,14 @@ export default function Devlogs({ me }) {
|
|||||||
</span>
|
</span>
|
||||||
{project && <span className="card-project">{project}</span>}
|
{project && <span className="card-project">{project}</span>}
|
||||||
<span className="card-rel">{relative(d.posted_at)}</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 && (
|
{me?.admin && (
|
||||||
<button
|
<button
|
||||||
className="card-delete"
|
className="card-delete"
|
||||||
title="Aus dem Archiv löschen"
|
title="Aus dem Archiv löschen"
|
||||||
onClick={() => remove(d.message_id)}
|
onClick={() => remove(d.message_id)}
|
||||||
>
|
>
|
||||||
✕
|
<IconX size={15} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,13 +188,13 @@ export default function Devlogs({ me }) {
|
|||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
← Neuere
|
<IconChevronLeft size={14} /> Neuere
|
||||||
</button>
|
</button>
|
||||||
<span className="pager-info">
|
<span className="pager-info">
|
||||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
Ältere →
|
Ältere <IconChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { IconStar, IconChevronRight } from '../icons.jsx';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
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>}
|
{e.cover && <a href={e.url} target="_blank" rel="noreferrer"><img className="event-cover" src={e.cover} alt="" /></a>}
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<span className="card-date">{e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'}</span>
|
<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>
|
</div>
|
||||||
<h3 className="release-name">{e.name}</h3>
|
<h3 className="release-name">{e.name}</h3>
|
||||||
{e.description && (
|
{e.description && (
|
||||||
@@ -46,7 +47,7 @@ export default function Events() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="card-footer">
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
|
import { IconChevronLeft, IconChevronRight } from '../icons.jsx';
|
||||||
import Lightbox from '../components/Lightbox.jsx';
|
import Lightbox from '../components/Lightbox.jsx';
|
||||||
import { SkeletonGrid } from '../components/Skeleton.jsx';
|
import { SkeletonGrid } from '../components/Skeleton.jsx';
|
||||||
|
|
||||||
@@ -68,13 +69,13 @@ export default function Galerie() {
|
|||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
← Neuere
|
<IconChevronLeft size={14} /> Neuere
|
||||||
</button>
|
</button>
|
||||||
<span className="pager-info">
|
<span className="pager-info">
|
||||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
Ältere →
|
Ältere <IconChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+26
-13
@@ -4,14 +4,18 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
import {
|
||||||
|
IconDevlog, IconRoadmap, IconServer, IconGallery, IconLevel, IconEvents,
|
||||||
|
IconMessage, IconArrowRight, IconLink,
|
||||||
|
} from '../icons.jsx';
|
||||||
|
|
||||||
const TILES = [
|
const TILES = [
|
||||||
['/devlogs', '📔', 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
|
['/devlogs', IconDevlog, 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
|
||||||
['/roadmap', '🗺️', 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
|
['/roadmap', IconRoadmap, 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
|
||||||
['/server', '🎮', 'Server', 'Live-Status aller Game-Server mit Verlauf'],
|
['/server', IconServer, 'Server', 'Live-Status aller Game-Server mit Verlauf'],
|
||||||
['/galerie', '🖼️', 'Galerie', 'Screenshots aus der Community'],
|
['/galerie', IconGallery, 'Galerie', 'Screenshots aus der Community'],
|
||||||
['/level', '📈', 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
|
['/level', IconLevel, 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
|
||||||
['/events', '📅', 'Events', 'Playtests, Streams und was sonst ansteht'],
|
['/events', IconEvents, 'Events', 'Playtests, Streams und was sonst ansteht'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Devlog-Text für den Teaser kürzen (erste Sätze, ohne Titel-Zeile) */
|
/** 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.
|
an einem Ort, direkt aus dem Discord.
|
||||||
</p>
|
</p>
|
||||||
<div className="home-cta">
|
<div className="home-cta">
|
||||||
{invite && <a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">💬 Zum Discord</a>}
|
{invite && (
|
||||||
<Link className="btn btn-ghost home-cta-alt" to="/devlogs">📔 Devlogs lesen</Link>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -88,9 +98,9 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="home-tiles">
|
<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}>
|
<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-title">{title}</span>
|
||||||
<span className="home-tile-desc">{desc}</span>
|
<span className="home-tile-desc">{desc}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -101,10 +111,13 @@ export default function Home() {
|
|||||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||||
<h2 className="settings-title">// Dienste</h2>
|
<h2 className="settings-title">// Dienste</h2>
|
||||||
<div className="home-tiles">
|
<div className="home-tiles">
|
||||||
|
{/* Dienst-Icons pflegt der Owner selbst — daher bleiben es Emojis */}
|
||||||
{services.map((s) => (
|
{services.map((s) => (
|
||||||
<a className="card home-tile" href={s.url} target="_blank" rel="noreferrer" key={s.id}>
|
<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-icon home-tile-emoji">{s.icon}</span>
|
||||||
<span className="home-tile-title">{s.name} <span className="home-tile-ext">↗</span></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>}
|
{s.description && <span className="home-tile-desc">{s.description}</span>}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -119,7 +132,7 @@ export default function Home() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<Markdown text={teaser(latest.content)} />
|
<Markdown text={teaser(latest.content)} />
|
||||||
</div>
|
</div>
|
||||||
<span className="home-devlog-more">Weiterlesen →</span>
|
<span className="home-devlog-more with-icon">Weiterlesen <IconArrowRight size={14} /></span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function Impressum() {
|
|||||||
<section className="content legal-text">
|
<section className="content legal-text">
|
||||||
{incomplete && (
|
{incomplete && (
|
||||||
<p className="notice">
|
<p className="notice">
|
||||||
⚠️ Die Impressums-Angaben sind noch nicht vollständig hinterlegt
|
✗ Die Impressums-Angaben sind noch nicht vollständig hinterlegt
|
||||||
(Setup → System → Impressum).
|
(Setup → System → Impressum).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
import { SkeletonRows } from '../components/Skeleton.jsx';
|
import { SkeletonRows } from '../components/Skeleton.jsx';
|
||||||
|
import { IconTrophy } from '../icons.jsx';
|
||||||
|
|
||||||
/** Muss der Formel in src/bot/levels.js entsprechen */
|
/** Muss der Formel in src/bot/levels.js entsprechen */
|
||||||
function xpForLevel(level) {
|
function xpForLevel(level) {
|
||||||
@@ -9,7 +10,8 @@ function xpForLevel(level) {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
// Platz 1–3 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 */
|
/** Nachrichten/Tag der letzten 30 Tage als Balken */
|
||||||
function ActivityChart({ stats }) {
|
function ActivityChart({ stats }) {
|
||||||
@@ -66,7 +68,7 @@ export default function Level() {
|
|||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
||||||
{!error && !rows && <SkeletonRows n={10} />}
|
{!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 && (
|
{rows?.length > 0 && (
|
||||||
<div className="lvl-list">
|
<div className="lvl-list">
|
||||||
@@ -76,7 +78,11 @@ export default function Level() {
|
|||||||
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
|
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
|
||||||
return (
|
return (
|
||||||
<div className="lvl-row" key={r.user_id}>
|
<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>
|
<span className="lvl-name">{r.username ?? r.user_id}</span>
|
||||||
<div className="lvl-progress">
|
<div className="lvl-progress">
|
||||||
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
|
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { apiGet, apiPost, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiDelete } from '../api.js';
|
||||||
|
import { IconCheck, IconMagic } from '../icons.jsx';
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
@@ -52,10 +53,10 @@ export default function Profil({ me }) {
|
|||||||
try {
|
try {
|
||||||
const res = await apiDelete('/api/profile');
|
const res = await apiDelete('/api/profile');
|
||||||
const total = Object.values(res.deleted).reduce((a, b) => a + b, 0);
|
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(() => {});
|
apiGet('/api/profile').then(setProfile).catch(() => {});
|
||||||
} 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>
|
||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <p className="notice">⚠️ {error}</p>}
|
{error && <p className="notice">✗ {error}</p>}
|
||||||
{!profile && !error && <p className="notice">Lade …</p>}
|
{!profile && !error && <p className="notice">Lade …</p>}
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
@@ -108,7 +109,9 @@ export default function Profil({ me }) {
|
|||||||
<h2 className="profile-name">{profile.user.displayName}</h2>
|
<h2 className="profile-name">{profile.user.displayName}</h2>
|
||||||
<p className="profile-meta">
|
<p className="profile-meta">
|
||||||
Dabei seit {fmtDate(profile.user.joinedAt)}
|
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>
|
</p>
|
||||||
{profile.roles.length > 0 && (
|
{profile.roles.length > 0 && (
|
||||||
<div className="profile-roles">
|
<div className="profile-roles">
|
||||||
@@ -145,7 +148,9 @@ export default function Profil({ me }) {
|
|||||||
<h2 className="settings-title">// Dein Alpha-Key</h2>
|
<h2 className="settings-title">// Dein Alpha-Key</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<code className="alpha-key">{profile.alphaKey}</code>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { apiGet, apiPost } from '../api.js';
|
import { apiGet, apiPost } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
import { IconThumbsUp } from '../icons.jsx';
|
||||||
|
|
||||||
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
@@ -104,20 +105,20 @@ export default function Roadmap() {
|
|||||||
? w
|
? w
|
||||||
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
||||||
} catch (e) {
|
} 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() {
|
async function submitWish() {
|
||||||
const text = idea.trim();
|
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 {
|
try {
|
||||||
await apiPost('/api/wishes', { idea: text });
|
await apiPost('/api/wishes', { idea: text });
|
||||||
setIdea('');
|
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(() => {});
|
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
|
||||||
} catch (e) {
|
} 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}`}>
|
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
|
||||||
{canVote && w.message_id ? (
|
{canVote && w.message_id ? (
|
||||||
<button
|
<button
|
||||||
className={`wish-vote${w.voted ? ' voted' : ''}`}
|
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
||||||
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
|
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
|
||||||
onClick={() => vote(w.message_id)}
|
onClick={() => vote(w.message_id)}
|
||||||
>
|
>
|
||||||
👍 {w.score}
|
<IconThumbsUp size={14} /> {w.score}
|
||||||
</button>
|
</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-idea">{w.idea}</span>
|
||||||
<span className="wish-author">{w.author}</span>
|
<span className="wish-author">{w.author}</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet } from '../api.js';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
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' });
|
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
@@ -68,9 +69,13 @@ function ServerCard({ s }) {
|
|||||||
<div className="srv-title">
|
<div className="srv-title">
|
||||||
<span className="srv-name">{s.name}</span>
|
<span className="srv-name">{s.name}</span>
|
||||||
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
|
<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.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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{s.online && s.players != null && (
|
{s.online && s.players != null && (
|
||||||
@@ -102,10 +107,14 @@ function ServerCard({ s }) {
|
|||||||
|
|
||||||
{(s.connect_url || s.address) && (
|
{(s.connect_url || s.address) && (
|
||||||
<div className="srv-connect">
|
<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 && (
|
{s.address && (
|
||||||
<button className="alpha-key srv-addr" onClick={copyAddress} title="Adresse kopieren">
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
|
import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
|
||||||
import EmbedComposer from '../components/EmbedComposer.jsx';
|
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 = [
|
const API_SCOPES = [
|
||||||
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
|
||||||
@@ -10,18 +15,18 @@ const API_SCOPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'status', icon: '📊', label: 'Status' },
|
{ id: 'status', Icon: IconStatus, label: 'Status' },
|
||||||
{ id: 'brand', icon: '🎨', label: 'Brand' },
|
{ id: 'brand', Icon: IconBrand, label: 'Brand' },
|
||||||
{ id: 'feeds', icon: '📔', label: 'Feeds' },
|
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
|
||||||
{ id: 'community', icon: '🙌', label: 'Community' },
|
{ id: 'community', Icon: IconCommunity, label: 'Community' },
|
||||||
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
{ id: 'rollen', Icon: IconRoles, label: 'Rollen' },
|
||||||
{ id: 'support', icon: '📬', label: 'Support' },
|
{ id: 'support', Icon: IconSupport, label: 'Support' },
|
||||||
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen' },
|
||||||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
{ id: 'composer', Icon: IconComposer, label: 'Composer' },
|
||||||
{ id: 'server', icon: '🎮', label: 'Server' },
|
{ id: 'server', Icon: IconServer, label: 'Server' },
|
||||||
{ id: 'system', icon: '⚙️', label: 'System' },
|
{ id: 'system', Icon: IconSystem, label: 'System' },
|
||||||
{ id: 'api', icon: '🔑', label: 'API' },
|
{ id: 'api', Icon: IconApi, label: 'API' },
|
||||||
{ id: 'team', icon: '👥', label: 'Team' },
|
{ id: 'team', Icon: IconTeam, label: 'Team' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||||||
@@ -574,7 +579,7 @@ export default function Settings({ me }) {
|
|||||||
{header}
|
{header}
|
||||||
<section className="content">
|
<section className="content">
|
||||||
<div className="notice center">
|
<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>}
|
{!me.user && <a className="btn" href="/auth/login">Login mit Discord</a>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -622,15 +627,15 @@ export default function Settings({ me }) {
|
|||||||
<div className="bot-avatar-wrap">
|
<div className="bot-avatar-wrap">
|
||||||
{data.status.botAvatar
|
{data.status.botAvatar
|
||||||
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
|
? <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 }} />
|
<span className="bot-dot" style={{ background: statusDot }} />
|
||||||
</div>
|
</div>
|
||||||
<label className="btn btn-upload">
|
<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])} />
|
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||||||
</label>
|
</label>
|
||||||
<label className="btn btn-upload btn-ghost">
|
<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])} />
|
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -844,7 +849,7 @@ export default function Settings({ me }) {
|
|||||||
onClick={distributeKeys}
|
onClick={distributeKeys}
|
||||||
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{alphaKeys?.assigned?.length > 0 && (
|
{alphaKeys?.assigned?.length > 0 && (
|
||||||
@@ -937,7 +942,7 @@ export default function Settings({ me }) {
|
|||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||||||
<p className="section-intro">
|
<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
|
ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind
|
||||||
jederzeit editierbar, der Discord-Post zieht automatisch mit.
|
jederzeit editierbar, der Discord-Post zieht automatisch mit.
|
||||||
</p>
|
</p>
|
||||||
@@ -1712,7 +1717,7 @@ export default function Settings({ me }) {
|
|||||||
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
>
|
>
|
||||||
<span className="settings-nav-icon">{t.icon}</span>
|
<span className="settings-nav-icon"><t.Icon /></span>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 { border-color: rgba(242, 63, 67, .5); color: #f23f43; }
|
||||||
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
|
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
|
||||||
.home-tile-ext { font-size: .8rem; color: var(--muted2); }
|
.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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user