feat: Discord-Anmeldung -- im Panel einrichtbar, nicht in der .env
Auf Wunsch ueber die Oberflaeche statt ueber Umgebungsvariablen. Der Gewinn ist
nicht nur Bequemlichkeit: das Panel legt die REDIRECT-URI zum Kopieren hin, und
genau daran scheitert ein OAuth-Anlauf fast immer.
settings.ts Schluessel/Wert in SQLite, mit Umgebung als Rueckfall. Wer
schon Stack-Variablen gesetzt hat, behaelt sie; das Panel
uebersteuert. In der Umgebung bleibt, was den Dienst ueberhaupt
erreichbar macht (PUBLIC_URL, PORT, DATA_DIR) -- wer das per
Formular verstellen koennte, sperrt sich mit einem Klick aus.
discord.ts Der Weg in drei Schritten, ohne Bibliothek und OHNE BOT-TOKEN:
die Rolle wird mit dem Merkmal des BENUTZERS geholt
(guilds.members.read). Kein Bot, keine Bot-Rechte, kein zweites
Geheimnis, das ablaufen kann.
DAS GEHEIMNIS GEHT NIE HERAUS -- auch nicht an eine angemeldete Sitzung. Das
Panel bekommt nur "hatGeheimnis: true". Ein Formular, das den Client-Schluessel
im Klartext zurueckschickt, streut ihn in jeden Browser-Cache. Ein leeres Feld
heisst "unveraendert", nicht "loeschen"; zum Entfernen gibt es "-".
JEDER ABBRUCH IST SICHTBAR: kein weisser Bildschirm, sondern zurueck aufs
Formular mit dem Grund im Adressfeld -- "Du bist nicht auf diesem Server",
"Dir fehlt die noetige Rolle", oder bei 4xx vom Tausch der Hinweis auf die
Redirect-URI. Die IDs werden schon beim Speichern geprueft (17-20 Ziffern),
damit ein halber Link nicht erst beim Anmelden auffaellt.
Das Passwort bleibt als Notausgang. Ein ueber Discord angelegtes Konto bekommt
KEIN brauchbares Passwort ("nur-ueber-discord"): verifyPassword scheitert an
allem, was nicht dem scrypt-Format entspricht -- nachgesehen, nicht vermutet.
Dazu der Verlauf, wie gewuenscht: Suche nach Pfad (mit derselben
LIKE-Maskierung wie in der Galerie -- Suche nach "_" findet 0 statt allem),
waehlbare Seitengroesse (25/50/100/200, Standard 50) und mehr Luft zwischen
"Groesse" und "Von wem".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,16 +6,18 @@ import {
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserCog,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { api, type EventKind, type EventsSeite } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||
|
||||
const PRO_SEITE = 100
|
||||
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
||||
|
||||
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
|
||||
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
|
||||
@@ -30,24 +32,37 @@ export function Verlauf() {
|
||||
const [seite, setSeite] = useState(0)
|
||||
const [art, setArt] = useState<EventKind | ''>('')
|
||||
const [wer, setWer] = useState('')
|
||||
const [proSeite, setProSeite] = useState(50)
|
||||
const [suche, setSuche] = useState('')
|
||||
|
||||
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
||||
// "vehicles" eine Anfrage je Buchstabe raus.
|
||||
const [sucheAktiv, setSucheAktiv] = useState('')
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setSucheAktiv(suche)
|
||||
setSeite(0)
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [suche])
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
setLaedt(true)
|
||||
try {
|
||||
setDaten(await api.events(art, wer, PRO_SEITE, seite * PRO_SEITE))
|
||||
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}, [art, wer, seite, note])
|
||||
}, [art, wer, sucheAktiv, proSeite, seite, note])
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
}, [laden])
|
||||
|
||||
const items = daten?.items ?? []
|
||||
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / PRO_SEITE))
|
||||
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -90,6 +105,51 @@ export function Verlauf() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
|
||||
/>
|
||||
<input
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
|
||||
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
|
||||
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
|
||||
focus:outline-none"
|
||||
/>
|
||||
{suche && (
|
||||
<button
|
||||
onClick={() => setSuche('')}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
|
||||
aria-label="Suche leeren"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-schrift-leise">
|
||||
pro Seite
|
||||
<select
|
||||
value={proSeite}
|
||||
onChange={(e) => {
|
||||
setProSeite(Number(e.target.value))
|
||||
setSeite(0)
|
||||
}}
|
||||
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
|
||||
text-schrift focus:border-akzent focus:outline-none"
|
||||
>
|
||||
{SEITENGROESSEN.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Karte>
|
||||
{items.length === 0 && !laedt ? (
|
||||
<Leer
|
||||
@@ -103,8 +163,8 @@ export function Verlauf() {
|
||||
<th className="pb-2 font-medium">Wann</th>
|
||||
<th className="pb-2 font-medium">Was</th>
|
||||
<th className="pb-2 font-medium">Pfad</th>
|
||||
<th className="pb-2 text-right font-medium">Größe</th>
|
||||
<th className="pb-2 font-medium">Von wem</th>
|
||||
<th className="pb-2 pr-8 text-right font-medium">Größe</th>
|
||||
<th className="pb-2 pl-2 font-medium">Von wem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -125,10 +185,10 @@ export function Verlauf() {
|
||||
<td className="max-w-0 truncate py-1.5 font-mono text-xs" title={e.path}>
|
||||
{e.path}
|
||||
</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
|
||||
<td className="py-1.5 pr-8 text-right whitespace-nowrap text-schrift-leise tabular-nums">
|
||||
{e.size === null ? '—' : bytes(e.size)}
|
||||
</td>
|
||||
<td className="py-1.5 whitespace-nowrap">
|
||||
<td className="py-1.5 pl-2 whitespace-nowrap">
|
||||
<span
|
||||
className={
|
||||
e.actor_kind === 'unbekannt'
|
||||
|
||||
Reference in New Issue
Block a user