From 4f149e365fd0823e70fd91df4ab6c317ee7c8878 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 18:59:42 +0200 Subject: [PATCH] feat: Discord-Bild und -Name im Dashboard Wer ueber Discord hereinkommt, sieht sein Bild in der Kopfzeile, auf der Kontoseite und neben seinen Eintraegen im Verlauf. DIE ADRESSE BAUT DER DIENST, nicht die Oberflaeche. Gespeichert wird nur der Bildstempel; das URL-Schema von Discord steht an EINER Stelle (avatarUrl in db.ts) und nicht in jeder Ansicht, die ein Bild zeigt. Es hat drei Faelle, und alle drei stehen dort: - Stempel faengt mit "a_" an -> bewegtes Bild, muss als .gif angefragt werden - Stempel vorhanden -> .png mit gewuenschter Groesse - kein Stempel -> Discords Ersatzbild, dessen Nummer sich aus der ID ergibt: (id >> 22) % 6. Das ist die NEUE Rechnung; die alte ueber den Diskriminator gilt seit den Pseudonymen nicht mehr. Gegengerechnet an drei IDs. Bild und Anzeigename werden BEI JEDER ANMELDUNG nachgezogen: wer sein Bild wechselt, saehe sonst monatelang das alte und fragte sich, ob er im richtigen Konto sitzt. Der Benutzername im Dashboard bleibt dagegen, wie er ist -- an ihm haengen die Eintraege im Verlauf. Der Avatar-Baustein kennt drei Faelle und keinen kaputten: Bild, sonst die Initialen auf einer Farbe, die sich aus dem Namen ergibt (damit sie beim Neuladen nicht springt), und fuer einen Token einen Schluessel -- ein Token hat kein Gesicht, ein Platzhalterbild daneben waere eine Behauptung. Laedt das Bild nicht (CDN nicht erreichbar, Stempel veraltet), faellt es auf die Initialen zurueck statt ein kaputtes Bildsymbol zu zeigen. Nachgemessen: cdn.discordapp.com liefert das Ersatzbild (200, image/png), die Form der Bildadresse stimmt, und ein Passwort-Konto bekommt sauber avatar:null. Co-Authored-By: Claude Opus 5 --- server/src/db.ts | 36 +++++++++++++ server/src/routes/dash.ts | 46 ++++++++++++++--- server/src/routes/discord.ts | 19 +++++-- server/ui/src/App.tsx | 24 ++++++--- server/ui/src/api.ts | 19 ++++++- server/ui/src/components/Avatar.tsx | 80 +++++++++++++++++++++++++++++ server/ui/src/pages/Konto.tsx | 27 +++++++++- server/ui/src/pages/Verlauf.tsx | 45 ++++++++++------ 8 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 server/ui/src/components/Avatar.tsx diff --git a/server/src/db.ts b/server/src/db.ts index 25d674a..e82bdd9 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -116,6 +116,15 @@ CREATE TABLE IF NOT EXISTS settings ( db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)') console.log('[db] Spalte users.discord_id angelegt') } + // Der Bildstempel, NICHT die fertige Adresse: Discord baut die URL aus ID + // und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe + // avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des + // Schemas eine Baustelle in jeder Zeile. + if (!spalten.some((s) => s.name === 'discord_avatar')) { + db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT') + db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT') + console.log('[db] Spalten users.discord_avatar und users.discord_name angelegt') + } } // Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur @@ -151,6 +160,33 @@ export type User = { /** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto * hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */ discord_id: string | null + /** Der Bildstempel von Discord, nicht die Adresse. Null heisst: kein + * eigenes Bild, dann gilt Discords Ersatzbild. */ + discord_avatar: string | null + discord_name: string | null +} + +/** Die Adresse des Discord-Bildes — an EINER Stelle gebaut. + * + * Faengt der Stempel mit "a_" an, ist es ein bewegtes Bild und muss als .gif + * angefragt werden; sonst kommt ein Standbild. Ohne eigenen Stempel gibt es + * Discords Ersatzbild, dessen Nummer sich aus der ID ergibt (die neue + * Rechnung mit >> 22, nicht die alte ueber den Diskriminator). + * + * Null nur, wenn gar keine Discord-ID da ist — dann zeigt die Oberflaeche + * Initialen. */ +export function avatarUrl( + discordId: string | null, + stempel: string | null, + groesse = 64, +): string | null { + if (!discordId) return null + if (stempel) { + const endung = stempel.startsWith('a_') ? 'gif' : 'png' + return `https://cdn.discordapp.com/avatars/${discordId}/${stempel}.${endung}?size=${groesse}` + } + const nummer = Number((BigInt(discordId) >> 22n) % 6n) + return `https://cdn.discordapp.com/embed/avatars/${nummer}.png` } export type Token = { diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 79bc118..4c944ea 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -10,6 +10,7 @@ import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' import { + avatarUrl, db, logEvent, now, @@ -198,7 +199,7 @@ dashRoutes.post('/auth/login', async (c) => { expires: new Date(session.expiresAt), }) - return c.json({ user: { id: user.id, username: user.username } }) + return c.json({ user: zeigen(user) }) }) dashRoutes.post('/auth/logout', (c) => { @@ -208,10 +209,23 @@ dashRoutes.post('/auth/logout', (c) => { return c.json({ ok: true }) }) +/** Wer gerade angemeldet ist — samt Bild, wenn es eines gibt. + * + * Die Adresse wird HIER gebaut und nicht in der Oberflaeche: Discords + * URL-Schema (gif bei bewegten Bildern, Ersatzbild aus der ID gerechnet) + * gehoert an eine Stelle und nicht in jede Ansicht, die ein Bild zeigt. */ +const zeigen = (user: User) => ({ + id: user.id, + username: user.username, + discord: Boolean(user.discord_id), + discordName: user.discord_name, + avatar: avatarUrl(user.discord_id, user.discord_avatar, 64), +}) + dashRoutes.get('/auth/me', (c) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) if (!user) return c.json({ error: 'nicht angemeldet' }, 401) - return c.json({ user: { id: user.id, username: user.username } }) + return c.json({ user: zeigen(user) }) }) /** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist. @@ -569,12 +583,32 @@ dashRoutes.get('/events', (c) => { // Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten // kann, ohne alle Eintraege durchzublaettern. - const akteure = db + const roheAkteure = db .prepare( - `SELECT actor, actor_kind, COUNT(*) AS n, MAX(at) AS zuletzt - FROM events GROUP BY actor, actor_kind ORDER BY n DESC`, + `SELECT e.actor, e.actor_kind, COUNT(*) AS n, MAX(e.at) AS zuletzt, + u.discord_id, u.discord_avatar + FROM events e + LEFT JOIN users u ON u.username = e.actor AND e.actor_kind = 'user' + GROUP BY e.actor, e.actor_kind ORDER BY n DESC`, ) - .all() as { actor: string; actor_kind: string; n: number; zuletzt: number }[] + .all() as { + actor: string + actor_kind: string + n: number + zuletzt: number + discord_id: string | null + discord_avatar: string | null + }[] + + // Das Bild nur fuer Menschen. Ein Token hat kein Gesicht, und ein + // Platzhalterbild daneben waere eine Behauptung. + const akteure = roheAkteure.map((a) => ({ + actor: a.actor, + actor_kind: a.actor_kind, + n: a.n, + zuletzt: a.zuletzt, + avatar: avatarUrl(a.discord_id, a.discord_avatar, 32), + })) // Ab wann aufgezeichnet wird. Alles davor steht nur in der Galerie, und // das gehoert dazugesagt statt verschwiegen. diff --git a/server/src/routes/discord.ts b/server/src/routes/discord.ts index dafb968..daa869c 100644 --- a/server/src/routes/discord.ts +++ b/server/src/routes/discord.ts @@ -126,6 +126,7 @@ discordRoutes.get('/discord/callback', async (c) => { id: string username: string global_name?: string | null + avatar?: string | null } // -- Und welche Rolle hat er hier ----------------------------------- @@ -160,8 +161,9 @@ discordRoutes.get('/discord/callback', async (c) => { | User | undefined + const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40) + if (!user) { - const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40) // Der Name muss eindeutig sein; zwei Leute koennen denselben // Anzeigenamen haben. Bei Kollision haengt die Discord-ID dran. const belegt = db.prepare('SELECT 1 FROM users WHERE username = ?').get(anzeige) @@ -172,13 +174,22 @@ discordRoutes.get('/discord/callback', async (c) => { // kommt also ausschliesslich ueber Discord herein. const info = db .prepare( - `INSERT INTO users (username, password_hash, created_at, discord_id) - VALUES (?, 'nur-ueber-discord', ?, ?)`, + `INSERT INTO users (username, password_hash, created_at, discord_id, + discord_avatar, discord_name) + VALUES (?, 'nur-ueber-discord', ?, ?, ?, ?)`, ) - .run(username, now(), me.id) + .run(username, now(), me.id, me.avatar ?? null, anzeige) user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User console.log(`[discord] Konto angelegt: ${username} (${me.id})`) + } else { + // BEI JEDER ANMELDUNG nachziehen. Wer sein Bild wechselt, sieht + // sonst monatelang das alte — und wundert sich, ob er im richtigen + // Konto sitzt. Der ANZEIGENAME im Dashboard bleibt dagegen, wie er + // ist: an ihm haengen die Eintraege im Verlauf. + db.prepare( + 'UPDATE users SET discord_avatar = ?, discord_name = ? WHERE id = ?', + ).run(me.avatar ?? null, anzeige, user.id) } const session = createSession(user.id) diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 028827e..c0bf0ad 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -8,6 +8,7 @@ import { Tokens } from './pages/Tokens' import { Konto } from './pages/Konto' import { Verlauf } from './pages/Verlauf' import { Meldungen } from './components/Meldungen' +import { Avatar } from './components/Avatar' // Ein winziger Router über die History-API statt einer Bibliothek. // @@ -120,14 +121,21 @@ export function App() { })} - +
+ + + {user.username} + + +
diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index a7c89ea..8cbc18e 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -89,7 +89,16 @@ const del = (path: string) => request(path, { method: 'DELETE' }) // created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die // beim nächsten Feld vergessen wird. -export type User = { id: number; username: string } +export type User = { + id: number + username: string + /** Kam dieses Konto ueber Discord herein? */ + discord: boolean + discordName: string | null + /** Fertige Adresse vom Dienst — das URL-Schema von Discord steht dort und + * nicht hier. Null, wenn es kein Discord-Konto ist. */ + avatar: string | null +} export type MediaItem = { id: number @@ -186,7 +195,13 @@ export type EventsSeite = { limit: number offset: number items: MediaEvent[] - akteure: { actor: string; actor_kind: string; n: number; zuletzt: number }[] + akteure: { + actor: string + actor_kind: string + n: number + zuletzt: number + avatar: string | null + }[] /** Ab wann aufgezeichnet wird — null, solange nichts drinsteht. */ seit: number | null } diff --git a/server/ui/src/components/Avatar.tsx b/server/ui/src/components/Avatar.tsx new file mode 100644 index 0000000..7524ba1 --- /dev/null +++ b/server/ui/src/components/Avatar.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { KeyRound } from 'lucide-react' + +/** Ein Bild zu einem Namen — oder ein ehrlicher Ersatz. + * + * Drei Fälle, und jeder sieht anders aus: + * + * Bild da → das Bild von Discords CDN + * kein Bild → die Initialen auf farbigem Grund + * kein Mensch → ein Schlüssel, denn ein Token hat kein Gesicht + * + * Der Fehlerfall ist mitgedacht: lädt das Bild nicht (CDN nicht erreichbar, + * Stempel veraltet), fällt es auf die Initialen zurück statt ein kaputtes + * Bildsymbol zu zeigen. */ +export function Avatar({ + name, + url, + token, + groesse = 24, +}: { + name: string + url?: string | null + token?: boolean + groesse?: number +}) { + const [gefallen, setGefallen] = useState(false) + const stil = { width: groesse, height: groesse } + + if (token) { + return ( + + + + ) + } + + if (url && !gefallen) { + return ( + {name} setGefallen(true)} + className="shrink-0 rounded-full object-cover" + style={stil} + /> + ) + } + + // Die Initialen. Die Farbe ergibt sich aus dem Namen, damit derselbe Name + // immer dieselbe Farbe bekommt — sonst springt sie bei jedem Neuladen. + const initialen = name + .split(/[\s._-]+/) + .filter(Boolean) + .slice(0, 2) + .map((t) => t[0]?.toUpperCase() ?? '') + .join('') + + let summe = 0 + for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360 + + return ( + + {initialen || '?'} + + ) +} diff --git a/server/ui/src/pages/Konto.tsx b/server/ui/src/pages/Konto.tsx index d38bcf7..1ea0ff0 100644 --- a/server/ui/src/pages/Konto.tsx +++ b/server/ui/src/pages/Konto.tsx @@ -3,6 +3,7 @@ import { api } from '../api' import { run, useStore } from '../store' import { Feld, Karte, Knopf } from '../components/Bausteine' import { DiscordKarte } from '../components/DiscordKarte' +import { Avatar } from '../components/Avatar' export function Konto() { const user = useStore((s) => s.user) @@ -37,9 +38,33 @@ export function Konto() { } } + // Der Discord-Name steht nur daneben, wenn er vom Anzeigenamen abweicht — + // sonst stuende dort zweimal dasselbe. + const herkunft = + user?.discordName && user.discordName !== user.username + ? `über Discord angemeldet — dort „${user.discordName}"` + : 'über Discord angemeldet' + return (
- + +
+ +
+

{user?.username ?? '—'}

+

+ {user?.discord ? herkunft : 'mit Passwort angemeldet'} +

+
+
+ + {user?.discord && ( +

+ Dieses Konto hat kein Passwort — es kommt ausschließlich über Discord + herein. Das Formular unten wirkt nur für Konten mit Passwort. +

+ )} +
void laden(), blockiert: seite > 0 }) const items = daten?.items ?? [] + // Die Bilder stehen bei den Akteuren und nicht an jedem Eintrag — sonst + // schleppte jede der 200 Zeilen dieselbe Adresse mit. + const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar])) const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite)) return ( @@ -94,11 +96,14 @@ export function Verlauf() { onClick={() => { setWer(a.actor); setSeite(0) }} text={`${a.actor} (${a.n})`} kind={ - a.actor_kind === 'token' ? ( - - ) : a.actor_kind === 'user' ? ( - - ) : null + a.actor_kind === 'unbekannt' ? null : ( + + ) } /> ))} @@ -225,14 +230,24 @@ export function Verlauf() { {e.size === null ? '—' : bytes(e.size)} - - {e.actor} + + {e.actor_kind !== 'unbekannt' && ( + + )} + + {e.actor} +