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 <noreply@anthropic.com>
This commit is contained in:
+16
-8
@@ -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() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => void run(logout, 'Abgemeldet.')}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
|
||||
text-schrift-leise hover:text-schrift"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Abmelden
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={user.username} url={user.avatar} groesse={26} />
|
||||
<span className="hidden text-sm text-schrift-leise sm:inline">
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void run(logout, 'Abgemeldet.')}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
|
||||
text-schrift-leise hover:text-schrift"
|
||||
aria-label="Abmelden"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
<span className="hidden md:inline">Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+17
-2
@@ -89,7 +89,16 @@ const del = <T,>(path: string) => request<T>(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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center rounded border border-rand bg-flaeche-hoch text-schrift-leise"
|
||||
style={stil}
|
||||
title="Token"
|
||||
>
|
||||
<KeyRound size={Math.round(groesse * 0.55)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (url && !gefallen) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={name}
|
||||
title={name}
|
||||
loading="lazy"
|
||||
onError={() => 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 (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center rounded-full font-medium text-white"
|
||||
style={{
|
||||
...stil,
|
||||
backgroundColor: `hsl(${summe} 45% 40%)`,
|
||||
fontSize: Math.round(groesse * 0.42),
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{initialen || '?'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex max-w-2xl flex-col gap-4">
|
||||
<Karte titel={`Angemeldet als ${user?.username ?? '—'}`}>
|
||||
<Karte titel="Konto">
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-rand pb-4">
|
||||
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={44} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{user?.username ?? '—'}</p>
|
||||
<p className="text-xs text-schrift-leise">
|
||||
{user?.discord ? herkunft : 'mit Passwort angemeldet'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.discord && (
|
||||
<p className="mb-4 rounded-md border border-rand bg-grund px-3 py-2 text-xs text-schrift-leise">
|
||||
Dieses Konto hat kein Passwort — es kommt ausschließlich über Discord
|
||||
herein. Das Formular unten wirkt nur für Konten mit Passwort.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={wechseln} className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Aktuelles Passwort"
|
||||
|
||||
@@ -3,13 +3,11 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserCog,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { api, type EventKind, type EventsSeite } from '../api'
|
||||
@@ -17,6 +15,7 @@ import { useStore } from '../store'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||
import { useAuffrischen } from '../auffrischen'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
||||
|
||||
@@ -69,6 +68,9 @@ export function Verlauf() {
|
||||
const frisch = useAuffrischen({ laden: () => 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' ? (
|
||||
<KeyRound size={12} />
|
||||
) : a.actor_kind === 'user' ? (
|
||||
<UserCog size={12} />
|
||||
) : null
|
||||
a.actor_kind === 'unbekannt' ? null : (
|
||||
<Avatar
|
||||
name={a.actor}
|
||||
url={a.avatar}
|
||||
token={a.actor_kind === 'token'}
|
||||
groesse={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -225,14 +230,24 @@ export function Verlauf() {
|
||||
{e.size === null ? '—' : bytes(e.size)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 whitespace-nowrap">
|
||||
<span
|
||||
className={
|
||||
e.actor_kind === 'unbekannt'
|
||||
? 'text-schrift-leise italic'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{e.actor}
|
||||
<span className="flex items-center gap-2">
|
||||
{e.actor_kind !== 'unbekannt' && (
|
||||
<Avatar
|
||||
name={e.actor}
|
||||
url={bilder.get(e.actor)}
|
||||
token={e.actor_kind === 'token'}
|
||||
groesse={18}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
e.actor_kind === 'unbekannt'
|
||||
? 'text-schrift-leise italic'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{e.actor}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user