feat: verschieben, Ordner, Adressvorlagen, API-Seite mit Export

VERSCHIEBEN. Dateien in einen anderen Ordner legen -- mit einer Warnung, die
OBEN steht und nicht im Kleingedruckten: der Pfad IST die oeffentliche Adresse,
nach dem Verschieben fuehrt die alte URL ins Leere. Das ist kein Fehler,
sondern der Preis vorhersagbarer Adressen; man muss es nur vorher wissen.

Der Umzug ist ein `rename` und kein Kopieren-dann-Loeschen: auf derselben
Platte ist das unteilbar. Die Vorschau zieht mit, sonst zeigte die Galerie am
neuen Ort nichts und am alten das Bild einer Datei, die dort nicht mehr liegt.
Eine belegte Zieladresse wird NICHT still ueberschrieben -- da verschwaende
sonst eine fremde Datei, und der Verlauf zeigte nur einen Umzug.

Der Verlauf kennt dafuer eine neue Spalte `von` und eine neue Art `move`; ohne
sie stuende dort nur das Ziel und die Frage "wo war die vorher" waere nicht
mehr zu beantworten.

ORDNER anlegen und entfernen. Leere Ordner gibt es sonst nicht -- sie sind der
Teil eines Pfades vor dem letzten Schraegstrich, mehr nicht. Jetzt sind sie ein
echtes Verzeichnis, und /folders liest Tabelle und Platte zusammen. Ein Ordner
MIT Inhalt wird nicht mitgeloescht: das waere ein Knopf, der hunderte Dateien
wegraeumt, ohne sie zu zeigen.

ADRESSVORLAGEN. Der Bauplan einer oeffentlichen Adresse, benannt und
gespeichert (vehicles/{model}.webp). Sie werden auf der Startseite und der
API-Seite mit ausgefuellter Adresse gezeigt. Ohne Platzhalter oder mit ".." im
Muster wird abgelehnt -- beides nachgemessen.

API-SEITE. Anleitung fuer die eigenen Skripte, und der Punkt daran: die
Beispiele stehen MIT DER ECHTEN ADRESSE drin statt mit example.com. Wer
abschreibt, schreibt keinen Tippfehler ab. Dazu Export in drei Formen -- JSON,
CSV (Semikolon und CRLF, sonst steht in Excel alles in einer Spalte) und eine
fertige Lua-Tabelle Name -> Adresse, die man in eine Resource legt.

DREI FEHLER BEIM MESSEN GEFUNDEN:
- rm mit recursive:false wirft bei einem Verzeichnis EISDIR und sagt damit
  etwas ganz anderes, als der Fall ist. rmdir scheitert von selbst, wenn der
  Ordner nicht leer ist -- genau das gewuenschte Verhalten.
- In der Lua-Ausgabe war "\'" gemeint und "\'" geschrieben: in JavaScript ist
  das schlicht ein Apostroph, der Backslash faellt weg. Ein Modellname mit
  Apostroph haette die erzeugte Datei zerlegt.
- Der Export mit Ordnerfilter antwortete mit einem NACKTEN 500 (kaputtes
  ESCAPE in der SQL-Zeile). Ausgerechnet das.

Deshalb hat der Dienst jetzt eine AUFFANGLINIE: app.onError gibt einen Text
statt "Internal Server Error". Wir haben das bisher an den bekannten Stellen
einzeln abgefangen -- und prompt hat ein Tippfehler die Luecke gefunden. Eine
Stelle schliesst sie fuer alles, woran heute niemand denkt.

Dazu: die Verlaufstabelle hat feste Spaltenbreiten (colgroup) und ueberall
dieselben Abstaende -- vorher hatten Kopf und Koerper verschiedene, und der
Pfad wurde abgeschnitten, obwohl daneben Platz frei war. Und die Anmeldeseite
hat zwei weiche Lichter statt einer leeren Flaeche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:01:01 +02:00
co-authored by Claude Opus 5
parent 32d69c9945
commit 67d2f4ec93
15 changed files with 1012 additions and 39 deletions
+16 -4
View File
@@ -120,6 +120,15 @@ CREATE TABLE IF NOT EXISTS settings (
// 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.
const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[]
if (!eSpalten.some((s) => s.name === 'von')) {
// Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese
// Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die
// vorher" waere nicht mehr zu beantworten.
db.exec('ALTER TABLE events ADD COLUMN von TEXT')
console.log('[db] Spalte events.von angelegt')
}
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')
@@ -210,7 +219,7 @@ export type Media = {
updated_at: number
}
export type EventKind = 'upload' | 'replace' | 'delete'
export type EventKind = 'upload' | 'replace' | 'delete' | 'move'
export type MediaEvent = {
id: number
@@ -220,13 +229,15 @@ export type MediaEvent = {
size: number | null
actor_kind: 'token' | 'user' | 'unbekannt'
actor: string
/** Nur bei 'move': der Pfad, unter dem die Datei vorher lag. */
von: string | null
}
export const now = () => Date.now()
const eventEinfuegen = db.prepare(
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
VALUES (?, ?, ?, ?, ?, ?)`,
`INSERT INTO events (at, kind, path, size, actor_kind, actor, von)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
/** Einen Eintrag in den Verlauf schreiben.
@@ -240,9 +251,10 @@ export function logEvent(
size: number | null,
actorKind: MediaEvent['actor_kind'],
actor: string,
von?: string,
): void {
try {
eventEinfuegen.run(now(), kind, path, size, actorKind, actor)
eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null)
} catch (err) {
console.error('[events] konnte nicht schreiben:', err)
}
+22
View File
@@ -37,6 +37,28 @@ const app = new Hono()
if (config.dev) app.use('*', logger())
// Ein unerwarteter Fehler bekommt einen TEXT, keinen nackten 500.
//
// Genau das ist der Fehler, wegen dem dieses Projekt existiert: bei Fivemanage
// endete jeder Fehlschlag als "Internal Server Error" ohne ein Wort dazu. Wir
// haben das an den bekannten Stellen einzeln abgefangen -- und prompt hat ein
// Tippfehler in einer SQL-Zeile beim Export genau diese nackte Antwort
// erzeugt. Eine Auffanglinie an EINER Stelle schliesst die Luecke fuer alles,
// woran heute niemand denkt.
//
// Die Meldung geht mit hinaus. Das ist eine bewusste Abwaegung: dieses
// Dashboard hat einen Benutzerkreis von einer Person, und ein Fehler, den man
// nicht lesen kann, kostet mehr als er verbirgt. Die Spur bleibt zusaetzlich
// in der Konsole.
app.onError((err, c) => {
console.error('[fehler]', c.req.method, c.req.path, err)
const text = err instanceof Error ? err.message : String(err)
if (c.req.path.startsWith('/api/')) {
return c.json({ error: text }, 500)
}
return c.text(`Serverfehler: ${text}`, 500)
})
// -------------------------------------------------------- Dateien ausliefern
/** Liefert eine Datei aus, mit ETag und Bereichsanfragen.
+283 -2
View File
@@ -31,7 +31,7 @@ import {
userForSession,
verifyPassword,
} from '../auth.js'
import { readdir, readFile, rm, stat } from 'node:fs/promises'
import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import {
DISCORD_KEYS,
@@ -40,7 +40,10 @@ import {
discordConfig,
getSetting,
setSetting,
setzeVorlagen,
sicherungConfig,
vorlagen,
type Vorlage,
} from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import {
@@ -49,6 +52,7 @@ import {
deleteFile,
kannVorschau,
mimeFor,
moveFile,
publicUrlFor,
safeFilename,
sha256,
@@ -414,7 +418,7 @@ dashRoutes.get('/arten', (c) => {
return c.json(out)
})
dashRoutes.get('/folders', (c) => {
dashRoutes.get('/folders', async (c) => {
const rows = db.prepare('SELECT path, size FROM media').all() as {
path: string
size: number
@@ -434,6 +438,25 @@ dashRoutes.get('/folders', (c) => {
}
}
// Leere Ordner gibt es nur auf der Platte -- sie stehen in keiner Tabelle,
// weil dort nur Dateien stehen. Wer einen anlegt, um etwas hineinzulegen,
// soll ihn trotzdem sehen.
async function verzeichnisse(rel: string, tiefe: number) {
let eintraege
try {
eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true })
} catch {
return
}
for (const e of eintraege) {
if (!e.isDirectory()) continue
const p = rel ? `${rel}/${e.name}` : e.name
if (!ordner.has(p)) ordner.set(p, { files: 0, bytes: 0, tiefe })
await verzeichnisse(p, tiefe + 1)
}
}
await verzeichnisse('', 0)
const items = [...ordner.entries()]
.map(([folder, wert]) => ({ folder, ...wert }))
// Alphabetisch und nicht nach Groesse: eine Baumansicht, deren
@@ -781,6 +804,7 @@ dashRoutes.get('/settings', (c) => {
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
},
},
vorlagen: vorlagen(),
sicherung: (() => {
const sc = sicherungConfig()
let letzte: unknown = null
@@ -805,6 +829,7 @@ dashRoutes.post('/settings', async (c) => {
guildId?: string
roleIds?: string | string[]
}
vorlagen?: { name?: string; muster?: string }[]
sicherung?: {
basis?: string
benutzer?: string
@@ -815,6 +840,50 @@ dashRoutes.post('/settings', async (c) => {
}>(c)
if (!body) return c.json(KEIN_JSON, 400)
// -- Adressvorlagen -----------------------------------------------------
if (body.vorlagen) {
if (!Array.isArray(body.vorlagen)) {
return c.json({ error: 'Vorlagen muessen eine Liste sein' }, 400)
}
if (body.vorlagen.length > 20) {
return c.json({ error: 'Hoechstens 20 Vorlagen' }, 400)
}
const sauber: Vorlage[] = []
for (const v of body.vorlagen) {
const name = String(v?.name ?? '').trim().slice(0, 40)
const muster = String(v?.muster ?? '').trim().replace(/^\/+/, '')
if (!name || !muster) continue
// Das Muster muss einen Platzhalter haben -- sonst ist es keine
// Vorlage, sondern eine einzelne Adresse.
if (!/\{[a-z]+\}/.test(muster)) {
return c.json(
{ error: `"${name}": im Muster fehlt ein Platzhalter, z. B. {model}` },
400,
)
}
// Und es muss ein gueltiger Pfad WERDEN koennen. Geprueft wird mit
// eingesetztem Platzhalter, sonst scheitert checkPath an der
// geschweiften Klammer.
try {
checkPath(muster.replace(/\{[a-z]+\}/g, 'x'))
} catch (err) {
return c.json(
{
error: `"${name}": ${err instanceof PathError ? err.message : 'ungueltiges Muster'}`,
},
400,
)
}
sauber.push({ name, muster })
}
setzeVorlagen(sauber)
if (!body.discord && !body.sicherung) {
return c.json({ ok: true, vorlagen: sauber })
}
}
// -- Sicherung ----------------------------------------------------------
if (body.sicherung) {
const sic = body.sicherung
@@ -1009,6 +1078,218 @@ dashRoutes.get('/uebersicht', (c) => {
ordner,
tokens,
discord: discordConfig().bereit,
vorlagen: vorlagen(),
})
})
/** Dateien in einen anderen Ordner legen.
*
* ACHTUNG, UND DIE OBERFLAECHE SAGT ES AUCH: der Pfad IST die oeffentliche
* Adresse. Wer ein Bild verschiebt, macht jede Stelle ungueltig, die die alte
* URL benutzt -- im Handy, in einer Vorlage, in einem Zwischenspeicher. Das
* ist kein Fehler dieser Funktion, sondern der Preis vorhersagbarer Adressen.
*
* Jede Datei bekommt ihr eigenes Ergebnis, und eine Kollision bricht die
* anderen nicht ab. */
dashRoutes.post('/media/move', async (c) => {
const body = await jsonBody<{ ids?: number[]; ziel?: string }>(c)
if (!body) return c.json(KEIN_JSON, 400)
if (!Array.isArray(body.ids) || body.ids.length === 0) {
return c.json({ error: 'keine Auswahl' }, 400)
}
let ziel = ''
if (body.ziel && body.ziel.trim()) {
try {
ziel = checkPath(body.ziel.trim().replace(/^\/+|\/+$/g, ''))
} catch (err) {
if (err instanceof PathError) return c.json({ error: `Ziel: ${err.message}` }, 400)
throw err
}
}
const wer = c.get('user').username
const ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] = []
for (const id of body.ids) {
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
if (!row) {
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
continue
}
const name = row.path.split('/').pop() as string
const neu = ziel ? `${ziel}/${name}` : name
if (neu === row.path) {
ergebnisse.push({ path: row.path, ok: false, error: 'liegt schon dort' })
continue
}
const belegt = db.prepare('SELECT 1 FROM media WHERE path = ?').get(neu)
if (belegt) {
// Bewusst KEIN stilles Ueberschreiben: hier wuerde eine fremde
// Datei verschwinden, und der Verlauf zeigte nur einen Umzug.
ergebnisse.push({ path: row.path, ok: false, error: `${neu} ist belegt` })
continue
}
try {
await moveFile(row.path, neu)
db.prepare('UPDATE media SET path = ?, updated_at = ? WHERE id = ?').run(
neu,
now(),
row.id,
)
logEvent('move', neu, row.size, 'user', wer, row.path)
ergebnisse.push({ path: row.path, ok: true, neu })
} catch (err) {
ergebnisse.push({
path: row.path,
ok: false,
error: err instanceof Error ? err.message : 'unbekannt',
})
}
}
const gut = ergebnisse.filter((e) => e.ok).length
return c.json({ verschoben: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
})
/** Einen leeren Ordner anlegen.
*
* Ordner entstehen hier sonst NEBENBEI: sie sind der Teil eines Pfades vor
* dem letzten Schraegstrich, mehr nicht. Ein leerer Ordner ist deshalb ein
* echtes Verzeichnis auf der Platte und in keiner Tabelle -- /folders liest
* beides und fuehrt es zusammen. */
dashRoutes.post('/folders', async (c) => {
const body = await jsonBody<{ pfad?: string }>(c)
if (!body?.pfad?.trim()) return c.json({ error: 'kein Pfad' }, 400)
let pfad: string
try {
pfad = checkPath(body.pfad.trim().replace(/^\/+|\/+$/g, ''))
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
await mkdir(join(config.filesDir, pfad), { recursive: true })
return c.json({ ok: true, pfad })
})
/** Einen Ordner entfernen -- nur wenn er leer ist.
*
* Ein Ordner mit Inhalt wird NICHT mitgeloescht: das waere ein Knopf, der
* hunderte Dateien wegraeumt, ohne sie zu zeigen. Wer sie loeschen will,
* waehlt sie in der Galerie aus und sieht dabei, was verschwindet. */
dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
let pfad: string
try {
pfad = checkPath(c.req.param('pfad'))
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
const drin = db
.prepare(`SELECT COUNT(*) AS n FROM media WHERE path LIKE ? ESCAPE '\\'`)
.get(`${pfad.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`) as { n: number }
if (drin.n > 0) {
return c.json({ error: `Der Ordner enthaelt noch ${drin.n} Dateien.` }, 409)
}
try {
// rmdir und nicht rm: es scheitert VON SELBST, wenn der Ordner nicht
// leer ist -- und genau das ist hier erwuenscht. `rm` ohne recursive
// wirft dagegen EISDIR und sagt damit etwas ganz anderes, als der
// Fall ist (einmal darauf hereingefallen).
await rmdir(join(config.filesDir, pfad))
} catch (err) {
return c.json(
{ error: err instanceof Error ? err.message : 'liess sich nicht entfernen' },
400,
)
}
// Die Vorschauen desselben Ordners hinterher, falls einer da ist.
await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch(
() => {},
)
return c.json({ ok: true })
})
/** Die Medienliste zum Mitnehmen.
*
* Wofuer: ein Skript, das die Adressen kennen muss, ohne die API zu fragen --
* eine Vorlage im Handy, eine Liste zum Abgleichen, ein Datensatz fuer eine
* andere Resource. Drei Formen, weil drei verschiedene Werkzeuge sie lesen.
*
* Die Lua-Form ist KEINE Spielerei: eine Datei, die man in eine Resource legt
* und mit `local urls = ...` einliest, spart genau den Zwischenschritt, an dem
* sonst jemand von Hand abtippt.
*
* Ohne Deckel: wer 900 Zeilen exportiert, will 900 Zeilen. Bei 228 Eintraegen
* sind das 40 KB. */
dashRoutes.get('/export', (c) => {
const form = c.req.query('format') ?? 'json'
const ordner = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
const where = ordner ? `WHERE path LIKE ? ESCAPE '\\'` : ''
const params = ordner ? [`${ordner.replace(/[\\%_]/g, (z) => `\\${z}`)}/%`] : []
const rows = (
db.prepare(`SELECT * FROM media ${where} ORDER BY path COLLATE NOCASE`).all(...params) as Media[]
).map((row) => ({ ...row, url: publicUrlFor(row.path) }))
const stempel = new Date().toISOString().slice(0, 10)
if (form === 'csv') {
// Semikolon: Excel in deutscher Einstellung nimmt sonst alles in eine
// Spalte, und dann ist die Datei fuer den haeufigsten Leser wertlos.
const kopf = 'pfad;url;groesse;typ;sha256;geaendert'
const zeilen = rows.map((r) =>
[r.path, r.url, r.size, r.mime, r.sha256, new Date(r.updated_at).toISOString()]
.map((f) => `"${String(f).replace(/"/g, '""')}"`)
.join(';'),
)
// CRLF, weil Excel es so erwartet — mit blossem \n steht die halbe
// Datei in einer Zelle.
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.csv"`,
})
}
if (form === 'lua') {
// Der Dateiname ohne Endung als Schluessel: genau danach sucht ein
// Skript ("adder"), nicht nach "vehicles/adder.webp".
const eintraege = rows.map((r) => {
const name = (r.path.split('/').pop() ?? r.path).replace(/\.[^.]+$/, '')
// "\\'" und nicht "\'": in JavaScript ist Letzteres schlicht ein
// Apostroph, der Backslash faellt weg — und ein Modellname mit
// Apostroph zerlegte damit die erzeugte Lua-Datei.
return ` ['${name.replace(/'/g, "\\'")}'] = '${r.url}',`
})
const text = [
'-- Erzeugt von d4rk_media am ' + new Date().toISOString(),
'-- ' + rows.length + ' Eintraege' + (ordner ? ` aus ${ordner}/` : ''),
'--',
'-- Verwendung: local urls = require(...) bzw. in einer Resource',
'-- als shared_script einbinden und Urls.<name> lesen.',
'Urls = {',
...eintraege,
'}',
'',
].join('\n')
return c.body(text, 200, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.lua"`,
})
}
return c.body(JSON.stringify({ erzeugt: now(), anzahl: rows.length, items: rows }, null, 2), 200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="d4rk_media-${stempel}.json"`,
})
})
+38
View File
@@ -44,6 +44,44 @@ export function ausDatenbank(key: string): boolean {
return Boolean(zeile && zeile.value !== '')
}
// ------------------------------------------------------------ Adressvorlagen
//
// Eine Vorlage ist der Bauplan einer oeffentlichen Adresse:
//
// vehicles/{model}.webp -> https://…/f/vehicles/adder.webp
//
// Wozu speichern? Weil genau diese Zeichenkette an der anderen Seite gebraucht
// wird -- im Handy, im Skript, im Fotostudio -- und weil sie sich jemand sonst
// aus Ordnername plus Endung zusammenreimt. Einmal falsch zusammengereimt
// heisst: das Bild fehlt, und gesucht wird an der falschen Stelle.
//
// Als JSON in EINER Zeile der Einstellungstabelle. Eine eigene Tabelle fuer
// eine Handvoll Zeilen waere eine Wanderung mehr, die niemand braucht.
export const VORLAGEN_KEY = 'vorlagen'
export type Vorlage = { name: string; muster: string }
export function vorlagen(): Vorlage[] {
try {
const roh = getSetting(VORLAGEN_KEY)
if (!roh) return []
const daten = JSON.parse(roh)
if (!Array.isArray(daten)) return []
return daten.filter(
(v): v is Vorlage =>
v && typeof v.name === 'string' && typeof v.muster === 'string',
)
} catch {
// Ein kaputter Eintrag darf die Einstellungsseite nicht mitreissen.
return []
}
}
export function setzeVorlagen(liste: Vorlage[]): void {
setSetting(VORLAGEN_KEY, JSON.stringify(liste))
}
// ----------------------------------------------------------------- Sicherung
//
// Die Werte fuer die Sicherung stehen hier, obwohl das Skript sie braucht und
+24
View File
@@ -221,6 +221,30 @@ export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
}
}
/** Eine Datei samt Vorschau an einen anderen Pfad legen.
*
* DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren
* mit anschliessendem Loeschen, sondern ein `rename`: das ist auf derselben
* Platte unteilbar. Ein abgebrochenes Kopieren hinterliesse die Datei zweimal
* oder gar nicht.
*
* Die Vorschau zieht mit. Bliebe sie liegen, zeigte die Galerie am neuen Ort
* kein Bild und am alten Ort das eines Bildes, das dort nicht mehr liegt. */
export async function moveFile(von: string, nach: string): Promise<void> {
const ziel = absolutePath(nach)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(von), ziel)
try {
const thumbZiel = thumbPath(nach)
await mkdir(dirname(thumbZiel), { recursive: true })
await rename(thumbPath(von), thumbZiel)
} catch {
// Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit
// neu rechnen.
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild
+3
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import {
Code2,
Gauge,
HardDrive,
History,
@@ -19,6 +20,7 @@ import { Tokens } from './pages/Tokens'
import { Konto } from './pages/Konto'
import { Einstellungen } from './pages/Einstellungen'
import { Verlauf } from './pages/Verlauf'
import { Api } from './pages/Api'
import { Meldungen } from './components/Meldungen'
import { gehZu, usePfad } from './router'
import { Avatar } from './components/Avatar'
@@ -38,6 +40,7 @@ const seiten = [
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
] as const
+20 -1
View File
@@ -171,6 +171,15 @@ export type Uebersicht = {
ordner: { folder: string; files: number; bytes: number }[]
tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[]
discord: boolean
vorlagen: Vorlage[]
}
export type Vorlage = { name: string; muster: string }
export type VerschiebeErgebnis = {
verschoben: number
fehlgeschlagen: number
ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[]
}
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
@@ -197,6 +206,7 @@ export type LetzteSicherung = {
} | null
export type Einstellungen = {
vorlagen: Vorlage[]
sicherung: {
basis: string
benutzer: string
@@ -224,7 +234,7 @@ export type Einstellungen = {
}
}
export type EventKind = 'upload' | 'replace' | 'delete'
export type EventKind = 'upload' | 'replace' | 'delete' | 'move'
export type MediaEvent = {
id: number
@@ -234,6 +244,8 @@ export type MediaEvent = {
size: number | null
actor_kind: 'token' | 'user' | 'unbekannt'
actor: string
/** Nur bei 'move': wo die Datei vorher lag. */
von: string | null
}
export type EventsSeite = {
@@ -313,6 +325,11 @@ export const api = {
`/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
),
folders: () => get<Folders>('/api/dash/folders'),
verschieben: (ids: number[], ziel: string) =>
post<VerschiebeErgebnis>('/api/dash/media/move', { ids, ziel }),
ordnerAnlegen: (pfad: string) => post<{ ok: true; pfad: string }>('/api/dash/folders', { pfad }),
ordnerEntfernen: (pfad: string) =>
del<{ ok: true }>('/api/dash/folders/' + pfad.split('/').map(encodeURIComponent).join('/')),
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
@@ -333,6 +350,8 @@ export const api = {
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
vorlagenSpeichern: (liste: Vorlage[]) =>
post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }),
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
+150
View File
@@ -0,0 +1,150 @@
import { useState } from 'react'
import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
import { api, type Folders, type MediaItem } from '../api'
import { run } from '../store'
import { Feld, Knopf } from './Bausteine'
/** Dateien in einen anderen Ordner legen.
*
* DIE WARNUNG STEHT OBEN UND NICHT IM KLEINGEDRUCKTEN. Der Pfad IST die
* öffentliche Adresse: wer ein Bild verschiebt, macht jede Stelle ungültig,
* die die alte URL benutzt — im Handy, in einer Vorlage, in einem
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
export function Verschieben({
dateien,
ordner,
onFertig,
onAbbruch,
}: {
dateien: MediaItem[]
ordner: Folders | null
onFertig: () => void
onAbbruch: () => void
}) {
const [ziel, setZiel] = useState('')
const [neuerOrdner, setNeuerOrdner] = useState('')
const [laeuft, setLaeuft] = useState(false)
const beispiel = dateien[0]
const name = beispiel?.path.split('/').pop() ?? ''
const neuerPfad = ziel ? `${ziel}/${name}` : name
async function verschieben() {
setLaeuft(true)
const r = await run(
() => api.verschieben(dateien.map((d) => d.id), ziel),
(x) =>
x.fehlgeschlagen === 0
? `${x.verschoben} verschoben.`
: `${x.verschoben} verschoben, ${x.fehlgeschlagen} nicht: ` +
x.ergebnisse
.filter((e) => !e.ok)
.slice(0, 3)
.map((e) => `${e.path} (${e.error})`)
.join('; '),
)
setLaeuft(false)
if (r) onFertig()
}
async function ordnerAnlegen() {
const pfad = neuerOrdner.trim()
if (!pfad) return
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
if (r) {
setZiel(r.pfad)
setNeuerOrdner('')
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onAbbruch}
>
<div
className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
<FolderInput size={16} className="text-akzent" />
{dateien.length === 1 ? 'Datei verschieben' : `${dateien.length} Dateien verschieben`}
</h3>
<div className="flex flex-col gap-3 px-4 py-4">
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-warn" />
<span className="text-schrift">
Der Pfad <strong>ist</strong> die öffentliche Adresse. Nach dem
Verschieben führt die alte URL ins Leere überall, wo sie steht.
</span>
</p>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Zielordner
</span>
<select
value={ziel}
onChange={(e) => setZiel(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
<option value="">(Wurzel)</option>
{ordner?.items.map((o) => (
<option key={o.folder} value={o.folder}>
{o.folder}
</option>
))}
</select>
</label>
<div className="flex items-end gap-2">
<Feld
label="oder neuen Ordner anlegen"
value={neuerOrdner}
placeholder="z. B. archiv/2026"
className="flex-1"
onChange={(e) => setNeuerOrdner(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void ordnerAnlegen()
}
}}
/>
<Knopf
disabled={!neuerOrdner.trim()}
onClick={() => void ordnerAnlegen()}
kind={<Plus size={15} />}
/>
</div>
{/* Zeigen, WAS daraus wird — nicht nur, wohin es geht. */}
{beispiel && (
<div className="rounded border border-rand bg-grund p-2 font-mono text-xs">
<div className="text-schrift-leise line-through">{beispiel.path}</div>
<div className="text-gut">{neuerPfad}</div>
{dateien.length > 1 && (
<div className="mt-1 text-schrift-leise">
und {dateien.length - 1} weitere
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
<Knopf onClick={onAbbruch} kind="Abbrechen" />
<Knopf
art="haupt"
wartet={laeuft}
onClick={() => void verschieben()}
kind="Verschieben"
/>
</div>
</div>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { Link2, Plus, Trash2 } from 'lucide-react'
import { api, type Vorlage } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine'
/** Adressvorlagen verwalten.
*
* Eine Vorlage ist der Bauplan einer öffentlichen Adresse:
* `vehicles/{model}.webp` wird zu `https://…/f/vehicles/adder.webp`.
*
* Wozu speichern? Weil genau diese Zeichenkette auf der anderen Seite
* gebraucht wird — im Handy, im Skript, im Fotostudio — und weil sie sich
* sonst jemand aus Ordnername plus Endung zusammenreimt. Einmal falsch
* zusammengereimt heißt: das Bild fehlt, und gesucht wird an der falschen
* Stelle. */
export function VorlagenKarte({ publicUrl, filePrefix }: { publicUrl: string; filePrefix: string }) {
const note = useStore((s) => s.note)
const [liste, setListe] = useState<Vorlage[] | null>(null)
const [speichert, setSpeichert] = useState(false)
const [name, setName] = useState('')
const [muster, setMuster] = useState('')
useEffect(() => {
api.einstellungen()
.then((e) => setListe(e.vorlagen))
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
async function sichern(neu: Vorlage[]) {
setSpeichert(true)
const r = await run(() => api.vorlagenSpeichern(neu), 'Vorlagen gespeichert.')
setSpeichert(false)
if (r) setListe(r.vorlagen)
return Boolean(r)
}
if (!liste) return null
return (
<Karte titel="Adressvorlagen">
<div className="flex flex-col gap-3">
{liste.length === 0 ? (
<p className="text-sm text-schrift-leise">
Noch keine. Eine Vorlage sagt, wie die öffentliche Adresse aussieht
etwa <code className="font-mono">vehicles/&#123;model&#125;.webp</code>.
</p>
) : (
<ul className="flex flex-col gap-2">
{liste.map((v, i) => (
<li
key={i}
className="flex items-center gap-2 rounded-md border border-rand bg-grund px-3 py-2"
>
<Link2 size={14} className="shrink-0 text-schrift-leise" />
<span className="w-32 shrink-0 truncate text-sm">{v.name}</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
{publicUrl}
{filePrefix}/{v.muster}
</code>
<button
onClick={() =>
void sichern(liste.filter((_, j) => j !== i))
}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${v.name} entfernen`}
>
<Trash2 size={13} />
</button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2 border-t border-rand pt-3">
<Feld
label="Name"
value={name}
placeholder="Fahrzeuge"
className="w-40"
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Muster"
value={muster}
placeholder="vehicles/{model}.webp"
hinweis="Ein Platzhalter in geschweiften Klammern ist Pflicht."
className="min-w-56 flex-1"
onChange={(e) => setMuster(e.target.value)}
/>
<Knopf
art="haupt"
wartet={speichert}
disabled={!name.trim() || !muster.trim()}
onClick={async () => {
if (
await sichern([
...liste,
{ name: name.trim(), muster: muster.trim() },
])
) {
setName('')
setMuster('')
}
}}
kind={
<>
<Plus size={15} />
Hinzufügen
</>
}
/>
</div>
</div>
</Karte>
)
}
+27 -4
View File
@@ -52,15 +52,34 @@ export function Anmelden() {
}
return (
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative flex min-h-full items-center justify-center overflow-hidden p-4">
{/* Zwei weiche Lichter im Hintergrund. Sie kosten nichts, brauchen
kein Bild und machen aus dem leeren Dunkel eine Seite. */}
<div
aria-hidden
className="pointer-events-none absolute -top-40 left-1/2 h-96 w-[36rem] -translate-x-1/2
rounded-full bg-akzent/20 blur-[100px]"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-48 right-1/4 h-80 w-96 rounded-full
bg-akzent-tief/30 blur-[100px]"
/>
<form
onSubmit={absenden}
className="w-full max-w-sm rounded-lg border border-rand bg-flaeche p-6"
className="relative w-full max-w-sm rounded-xl border border-rand bg-flaeche/80
p-7 shadow-2xl shadow-black/40 backdrop-blur"
>
<div className="mb-6 flex items-center gap-2">
<HardDrive size={20} className="text-akzent" />
<div className="mb-1 flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-akzent/15 text-akzent">
<HardDrive size={20} />
</span>
<h1 className="text-lg font-semibold">d4rk_media</h1>
</div>
<p className="mb-6 text-xs text-schrift-leise">
Bilder, Videos und Ton für die eigenen Ressourcen.
</p>
<div className="flex flex-col gap-3">
<Feld
@@ -93,6 +112,10 @@ export function Anmelden() {
kind="Anmelden"
/>
<p className="mt-4 text-center text-[11px] text-schrift-leise">
Nach fünf Fehlversuchen wird kurz gesperrt.
</p>
{discord && (
<>
<div className="my-4 flex items-center gap-3 text-xs text-schrift-leise">
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react'
import { Check, Copy, Download, FileJson, FileSpreadsheet, FileCode2 } from 'lucide-react'
import { api, type Uebersicht } from '../api'
import { useStore } from '../store'
import { Karte, useKopieren } from '../components/Bausteine'
/** Anleitung und Export für die eigenen Skripte.
*
* Der Punkt dieser Seite: die Beispiele stehen hier MIT DER ECHTEN ADRESSE
* drin, nicht mit `https://example.com`. Wer etwas abschreibt, schreibt damit
* auch keinen Tippfehler ab — und das ist die häufigste Fehlerquelle beim
* Anbinden eines Dienstes.
*
* Der Token steht bewusst NICHT hier. Er ist nach dem Anlegen nicht mehr
* lesbar, auch nicht für den Dienst; im Beispiel steht deshalb ein
* Platzhalter. */
export function Api() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<Uebersicht | null>(null)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
api.uebersicht()
.then(setDaten)
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
}, [note])
if (!daten) return null
const B = daten.publicUrl
const F = `${daten.publicUrl}${daten.filePrefix}`
async function kopiereBlock(text: string, marke: string) {
if (!(await kopieren(text, marke))) note('fehler', 'Die Zwischenablage ist gesperrt.')
}
const block = (marke: string, text: string) => (
<div className="relative">
<pre className="overflow-x-auto rounded-md border border-rand bg-grund p-3 font-mono text-xs leading-relaxed">
{text}
</pre>
<button
onClick={() => void kopiereBlock(text, marke)}
className="absolute top-2 right-2 rounded border border-rand bg-flaeche px-2 py-1
text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Kopieren"
>
{kopiert === marke ? <Check size={13} className="text-gut" /> : <Copy size={13} />}
</button>
</div>
)
return (
<div className="flex flex-col gap-4">
<Karte titel="Export">
<div className="flex flex-wrap items-center gap-2">
{(
[
['json', 'JSON', FileJson, 'Alles, was der Dienst weiß'],
['csv', 'CSV', FileSpreadsheet, 'für Tabellen (Semikolon, für Excel)'],
['lua', 'Lua', FileCode2, 'fertige Tabelle Name → Adresse'],
] as const
).map(([f, text, Symbol, hinweis]) => (
<a
key={f}
href={`/api/dash/export?format=${f}`}
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
title={hinweis}
>
<Symbol size={15} />
{text}
<Download size={13} className="opacity-60" />
</a>
))}
<p className="text-xs text-schrift-leise">
{daten.files} Einträge. Die Lua-Fassung liest man in einer Resource als{' '}
<code className="font-mono">Urls.adder</code> aus.
</p>
</div>
</Karte>
<Karte titel="Hochladen aus einer FiveM-Resource">
<p className="mb-2 text-xs text-schrift-leise">
Server-Seite, nicht NUI: die NUI läuft unter https und dürfte eine
http-Adresse gar nicht aufrufen. Base64, weil{' '}
<code className="font-mono">PerformHttpRequest</code> den Rumpf als
Lua-Zeichenkette nimmt und ein Bild voller Nullbytes ist.
</p>
{block(
'lua-upload',
`local TOKEN = 'd4rk_…' -- im Dashboard unter Token anlegen
local function hochladen(pfad, bytes, cb)
PerformHttpRequest('${B}/api/upload', function(status, body)
if status ~= 200 then return cb(nil, body) end
cb(json.decode(body).url)
end, 'POST', b64(bytes), {
['Authorization'] = 'Bearer ' .. TOKEN,
['X-Path'] = pfad, -- z. B. vehicles/adder.webp
['X-Encoding'] = 'base64',
['Content-Type'] = 'text/plain; charset=utf-8',
})
end`,
)}
</Karte>
<Karte titel="Die drei Wege der Skript-API">
<div className="flex flex-col gap-3">
{block(
'curl-upload',
`# Hochladen — rohe Bytes, X-Path bestimmt die spätere Adresse
curl -X POST ${B}/api/upload \\
-H "Authorization: Bearer d4rk_…" \\
-H "X-Path: vehicles/adder.webp" \\
--data-binary @adder.webp
# → {"url":"${F}/vehicles/adder.webp","size":…,"sha256":…,"replaced":false}`,
)}
{block(
'curl-exists',
`# Gibt es das schon? — für "nur fehlende" in einem Serienlauf
curl -H "Authorization: Bearer d4rk_…" \\
${B}/api/exists/vehicles/adder.webp
# → {"exists":true,"url":"${F}/vehicles/adder.webp","size":…}`,
)}
{block(
'curl-delete',
`# Löschen — der Token braucht dafür das Recht "darf löschen"
curl -X DELETE -H "Authorization: Bearer d4rk_…" \\
${B}/api/media/vehicles/adder.webp`,
)}
</div>
</Karte>
<Karte titel="Kopfzeilen beim Hochladen">
<table className="w-full text-sm">
<colgroup>
<col className="w-36" />
<col />
</colgroup>
<tbody>
{(
[
['X-Path', 'Wohin die Datei kommt. Ohne ihn wird aus dem Hash ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar.'],
['X-Encoding', '„base64", wenn der Rumpf base64-Text ist. Data-URL-Vorspann wird abgeschnitten.'],
['X-Overwrite', '„false" verweigert das Überschreiben und antwortet mit 409.'],
['X-Mime', 'Überschreibt die Art. Sonst entscheidet die Endung.'],
] as const
).map(([k, t]) => (
<tr key={k} className="border-t border-rand first:border-0">
<td className="py-2 pr-3 align-top font-mono text-xs">{k}</td>
<td className="py-2 text-schrift-leise">{t}</td>
</tr>
))}
</tbody>
</table>
</Karte>
<Karte titel="Adressen">
<div className="flex flex-col gap-2 text-sm">
{daten.vorlagen.length === 0 && (
<p className="text-xs text-schrift-leise">
Noch keine Vorlage angelegt das geht unter Einstellungen.
</p>
)}
{daten.vorlagen.map((v, i) => (
<div key={i} className="flex items-center gap-2">
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
{v.name}
</span>
<code className="min-w-0 flex-1 truncate rounded border border-rand bg-grund px-2 py-1 font-mono text-xs">
{F}/{v.muster}
</code>
<button
onClick={() => void kopiereBlock(`${F}/${v.muster}`, 'vl' + i)}
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Kopieren"
>
{kopiert === 'vl' + i ? (
<Check size={13} className="text-gut" />
) : (
<Copy size={13} />
)}
</button>
</div>
))}
</div>
</Karte>
</div>
)
}
+12
View File
@@ -5,6 +5,7 @@ import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
import { VorlagenKarte } from '../components/VorlagenKarte'
/** Alles, was sich einstellen lässt, an einer Stelle.
*
@@ -13,8 +14,19 @@ import { DiscordKarte } from '../components/DiscordKarte'
* als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht
* gelaufen ist. Diese Seite tut es. */
export function Einstellungen() {
const [urls, setUrls] = useState({ publicUrl: '', filePrefix: '' })
// Die Vorlagenkarte zeigt die FERTIGE Adresse und nicht nur das Muster —
// dafuer braucht sie den Ursprung, und der steht in der Uebersicht.
useEffect(() => {
api.uebersicht()
.then((u) => setUrls({ publicUrl: u.publicUrl, filePrefix: u.filePrefix }))
.catch(() => {})
}, [])
return (
<div className="flex flex-col gap-4">
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
<SicherungKarte />
<DiscordKarte />
</div>
+30
View File
@@ -9,6 +9,7 @@ import {
ArrowDownUp,
FileText,
Film,
FolderInput,
Layers,
Music,
ImageOff,
@@ -35,6 +36,7 @@ import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
import { Hochladen } from '../components/Hochladen'
import { Ansehen } from '../components/Ansehen'
import { Verschieben } from '../components/Verschieben'
import { useAuffrischen } from '../auffrischen'
const PRO_SEITE = 60
@@ -57,6 +59,7 @@ export function Galerie() {
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt:
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
const [ansehen, setAnsehen] = useState<number | null>(null)
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null)
const { kopiert, kopieren } = useKopieren()
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
@@ -384,6 +387,18 @@ export function Galerie() {
/>
)}
{gewaehlt.size > 0 && (
<Knopf
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))}
kind={
<>
<FolderInput size={15} />
verschieben
</>
}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
art="gefahr"
@@ -595,6 +610,21 @@ export function Galerie() {
)
})()}
{verschieben && (
<Verschieben
dateien={verschieben}
ordner={ordnerDaten}
onAbbruch={() => setVerschieben(null)}
onFertig={() => {
setVerschieben(null)
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void artenLaden()
}}
/>
)}
<Nachfrage
offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
+45 -17
View File
@@ -3,6 +3,7 @@ import {
Check,
Copy,
Folder,
FolderInput,
HardDrive,
Images,
KeyRound,
@@ -25,6 +26,7 @@ const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Uploa
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
}
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
@@ -66,7 +68,13 @@ export function Start() {
}
if (!daten) return <Leer text="Die Übersicht ließ sich nicht laden." />
const vorlage = `${daten.publicUrl}${daten.filePrefix}/vehicles/{model}.webp`
// Die eingetragenen Vorlagen — und solange keine da ist, die eine, die
// dieses Projekt ohnehin benutzt. Eine leere Karte waere hier nur ein
// Loch.
const vorlagen =
daten.vorlagen.length > 0
? daten.vorlagen
: [{ name: 'Fahrzeuge', muster: 'vehicles/{model}.webp' }]
return (
<div className="flex flex-col gap-4">
@@ -106,25 +114,45 @@ export function Start() {
</div>
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */}
<Karte titel="Adressvorlage">
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{vorlage}
</code>
<Karte
titel={vorlagen.length === 1 ? 'Adressvorlage' : 'Adressvorlagen'}
rechts={
<button
onClick={async () => {
if (!(await kopieren(vorlage, 'v')))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Vorlage kopieren"
onClick={() => gehZu('/einstellungen')}
className="text-xs text-schrift-leise hover:text-schrift"
>
{kopiert === 'v' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
verwalten
</button>
}
>
<div className="flex flex-col gap-2">
{vorlagen.map((v, i) => {
const voll = `${daten.publicUrl}${daten.filePrefix}/${v.muster}`
return (
<div key={i} className="flex items-center gap-2">
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
{v.name}
</span>
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{voll}
</code>
<button
onClick={async () => {
if (!(await kopieren(voll, 'v' + i)))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label={`${v.name} kopieren`}
>
{kopiert === 'v' + i ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
)
})}
</div>
</Karte>
+32 -11
View File
@@ -6,6 +6,7 @@ import {
Loader2,
RefreshCw,
Search,
FolderInput,
Trash2,
Upload,
X,
@@ -23,6 +24,7 @@ const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Uploa
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
}
export function Verlauf() {
@@ -198,14 +200,25 @@ export function Verlauf() {
text="Für diese Auswahl gibt es nichts."
/>
) : (
<table className="w-full text-sm">
<table className="w-full table-fixed text-sm">
{/* Die Spaltenbreiten stehen HIER und nicht verteilt an
den Zellen: vorher hatten Kopf und Koerper
verschiedene Abstaende, und der Pfad wurde
abgeschnitten, obwohl daneben Platz frei war. */}
<colgroup>
<col className="w-40" />
<col className="w-32" />
<col />
<col className="w-24" />
<col className="w-44" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<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 pr-8 text-right font-medium">Größe</th>
<th className="pb-2 pl-2 font-medium">Von wem</th>
<th className="px-3 pb-2 font-medium">Wann</th>
<th className="px-3 pb-2 font-medium">Was</th>
<th className="px-3 pb-2 font-medium">Pfad</th>
<th className="px-3 pb-2 text-right font-medium">Größe</th>
<th className="px-3 pb-2 font-medium">Von wem</th>
</tr>
</thead>
<tbody>
@@ -214,22 +227,30 @@ export function Verlauf() {
const Kind = a.kind
return (
<tr key={e.id} className="border-t border-rand">
<td className="py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(e.at)}
</td>
<td className={`py-1.5 whitespace-nowrap ${a.farbe}`}>
<td className={`px-3 py-1.5 whitespace-nowrap ${a.farbe}`}>
<span className="flex items-center gap-1.5">
<Kind size={13} />
{a.text}
</span>
</td>
<td className="max-w-0 truncate py-1.5 font-mono text-xs" title={e.path}>
<td
className="max-w-0 truncate px-3 py-1.5 font-mono text-xs"
title={e.von ? `${e.von}${e.path}` : e.path}
>
{e.von && (
<span className="text-schrift-leise">
{e.von} {' '}
</span>
)}
{e.path}
</td>
<td className="py-1.5 pr-8 text-right whitespace-nowrap text-schrift-leise tabular-nums">
<td className="px-3 py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
{e.size === null ? '—' : bytes(e.size)}
</td>
<td className="py-1.5 pl-2 whitespace-nowrap">
<td className="px-3 py-1.5 whitespace-nowrap">
<span className="flex items-center gap-2">
{e.actor_kind !== 'unbekannt' && (
<Avatar