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} +