feat: Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
UMBENENNEN gab es gar nicht -- verschieben ja, umbenennen nirgends. Jetzt fuer
Datei und Ordner. Die WARNUNG ist dabei der eigentliche Teil: beim Verschieben
wandert eine Datei, beim Umbenennen aendert sich IHRE ADRESSE, und die steht
womoeglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt
deshalb die Abrufzahl und beim Ordner die Zahl der betroffenen Dateien, BEVOR
gedrueckt wird -- eine Datei mit viertausend Abrufen umzubenennen ist etwas
anderes als eine mit null, und der Dienst ist die einzige Stelle, die den
Unterschied kennt.
Am echten Fall geprueft, dem Kollisionsfund: items/coiloverss.png (hielt die
+-Fassung) -> items/coiloverss-plus.png, alte Adresse 404, neue 200 mit 4375 B,
Vorschau mitgewandert. Ordner: probe -> beispiele, 5 Dateien, alle Pfade in
einer Transaktion umgeschrieben.
Ein Fallstrick dabei: thumbPath haengt ".webp" an. Fuer eine Datei richtig, fuer
einen ORDNER Unsinn -- der Vorschauordner heisst thumbs/vehicles und nicht
thumbs/vehicles.webp. Dafuer gibt es jetzt moveFolder.
DIE 93 KOLLISIONEN SIND ERLEDIGT. tools/kollisionen.ps1 rechnet dieselbe
Zaehmung auf der Quelle nach und zeigt, was zusammenfaellt -- es aendert nichts,
damit die Entscheidung auf Zahlen steht. Ergebnis: 91 von 93 sind dieselbe Datei
zweimal (WEAPON_SMG.png und weapon_smg.png, byteweise gleich gross), da fehlte
nichts. Echt verloren waren ZWEI, beide mit + im Namen. Beide nachgeholt.
FIVEMANAGE-SPRACHE. POST /api/image|video|audio (v1) und POST /api/v3/file (v3),
Schluessel nackt im Authorization-Kopf. Auf einem laufenden Server stecken die
Fivemanage-Aufrufe in einem Dutzend fremder Ressourcen; sie alle umzuschreiben
tut niemand, und deshalb bliebe dieser Dienst ungenutzt daneben stehen. So ist
der Umzug eine Zeile je Skript: die Adresse.
Beide Formen sind AUS ECHTEM CODE abgelesen und nicht geraten -- fivemanage/sdk
fuer v3, Awleks/Devm-Camera fuer den aelteren Weg ueber screenshot-basic.
Gemessen: v1 -> {url,id,path}, v3 -> {status:"ok",data:{id,url}}, ohne
Schluessel 401, ein Video an /api/image -> 415 mit Grund.
Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
warnt: die Token-Wache hing an use('*'), und der Einhaengepunkt ist /api -- also
galt sie auch fuer /api/dash daneben. Das Dashboard bekam 401 auf die ANMELDUNG.
Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.
FREIGABE-LINKS. /s/<schluessel> zeigt einen Ordner ohne Anmeldung. Was dabei
ausdruecklich dabeisteht, in der Karte und in der Rueckfrage vor dem
Zurueckziehen: FREIGEGEBEN WIRD DIE LISTE, NICHT DER INHALT. Die Dateien sind
ohnehin oeffentlich; ein zurueckgezogener Link macht sie nicht wieder privat, er
nimmt nur die Uebersicht weg. Ohne diesen Satz zieht jemand einen Link zurueck
und glaubt, etwas sei verschwunden.
Die Antwort ist abgemessen: Name, Groesse, Art, Adresse, Laenge. NICHT Hash,
Token, Zeitpunkte, Abrufzahlen, IDs -- nichts davon braucht, wer einen Katalog
ansieht, und jedes davon waere eine Auskunft ueber den Betrieb. Ein unbekannter
Schluessel und ein zurueckgezogener geben dieselbe Antwort.
ZAHLEN, DIE SCHON DA WAREN. Sortieren nach Abrufen und nach "zuletzt geholt";
die Kachel zeigt dann auch diese Zahl statt Groesse und Datum, denn nach etwas
zu ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begruendung. Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war NIRGENDS zu sehen. Gemessen: d4rk_photostudio haelt 810
Dateien / 99,5 MB, 3640 liegen ohne Token da.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { Einstellungen } from './pages/Einstellungen'
|
||||
import { Verlauf } from './pages/Verlauf'
|
||||
import { Api } from './pages/Api'
|
||||
import { Papierkorb } from './pages/Papierkorb'
|
||||
import { Freigabe } from './pages/Freigabe'
|
||||
import { Meldungen } from './components/Meldungen'
|
||||
import { gehZu, usePfad } from './router'
|
||||
import { Kontomenue } from './components/Kontomenue'
|
||||
@@ -63,6 +64,18 @@ export function App() {
|
||||
void checkSession()
|
||||
}, [checkSession])
|
||||
|
||||
// EINE FREIGABE GEHT VOR ALLEM ANDEREN -- vor dem Prüfen der Sitzung, vor
|
||||
// dem Anmeldeformular, vor der Kopfzeile. Wer einen Link bekommen hat, ist
|
||||
// kein Benutzer dieses Dashboards; ihm ein Anmeldeformular zu zeigen wäre
|
||||
// die falsche Antwort auf "sieh dir das an".
|
||||
//
|
||||
// Und deshalb steht das hier VOR dem `checking`-Zweig: sonst blitzt für
|
||||
// einen Moment das Rädchen der Sitzungsprüfung auf, die es gar nicht
|
||||
// braucht.
|
||||
if (pfad.startsWith('/s/')) {
|
||||
return <Freigabe schluessel={decodeURIComponent(pfad.slice(3))} />
|
||||
}
|
||||
|
||||
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
|
||||
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
|
||||
// angemeldet ist — und das sieht aus wie ein Fehler.
|
||||
|
||||
+45
-2
@@ -174,6 +174,12 @@ export type TokenRow = {
|
||||
arten: string
|
||||
expires_at: number | null
|
||||
abgelaufen: boolean
|
||||
/** Wieviel mit DIESEM Token abgelegt wurde. */
|
||||
dateien: number
|
||||
bytes: number
|
||||
/** Wann zuletzt etwas dabei herauskam — anders als `last_used_at`, das
|
||||
* auch ein Anklopfen ohne Ergebnis mitzaehlt. */
|
||||
zuletztAbgelegt: number | null
|
||||
}
|
||||
|
||||
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
||||
@@ -216,6 +222,22 @@ export type Uebersicht = {
|
||||
|
||||
export type Vorlage = { name: string; muster: string }
|
||||
|
||||
/** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann.
|
||||
*
|
||||
* Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin
|
||||
* oeffentlich. Das steht so auch in der Karte, weil das Gegenteil zu glauben
|
||||
* gefaehrlich waere. */
|
||||
export type FreigabeRow = {
|
||||
id: number
|
||||
schluessel: string
|
||||
ordner: string
|
||||
titel: string
|
||||
created_at: number
|
||||
aufrufe: number
|
||||
zuletzt: number | null
|
||||
url: string
|
||||
}
|
||||
|
||||
export type VerschiebeErgebnis = {
|
||||
verschoben: number
|
||||
fehlgeschlagen: number
|
||||
@@ -225,7 +247,7 @@ export type VerschiebeErgebnis = {
|
||||
export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere'
|
||||
export type Arten = Record<string, number>
|
||||
|
||||
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse'
|
||||
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' | 'abrufe' | 'geholt'
|
||||
export type Richtung = 'auf' | 'ab'
|
||||
|
||||
export type Verwaiste = {
|
||||
@@ -450,7 +472,28 @@ export const api = {
|
||||
{ aktion },
|
||||
),
|
||||
|
||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
||||
freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'),
|
||||
freigabeAnlegen: (ordner: string, titel: string) =>
|
||||
post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>(
|
||||
'/api/dash/freigaben',
|
||||
{ ordner, titel },
|
||||
),
|
||||
freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`),
|
||||
|
||||
tokens: () =>
|
||||
get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>(
|
||||
'/api/dash/tokens',
|
||||
),
|
||||
umbenennen: (id: number, name: string) =>
|
||||
post<{ alt: string; neu: string; url: string; abrufe: number }>(
|
||||
'/api/dash/media/umbenennen',
|
||||
{ id, name },
|
||||
),
|
||||
ordnerUmbenennen: (pfad: string, name: string) =>
|
||||
post<{ alt: string; neu: string; dateien: number }>('/api/dash/folders/umbenennen', {
|
||||
pfad,
|
||||
name,
|
||||
}),
|
||||
createToken: (daten: {
|
||||
name: string
|
||||
prefix: string
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
PenLine,
|
||||
Repeat,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -38,6 +39,7 @@ export function Ansehen({
|
||||
onNachher,
|
||||
onSchliessen,
|
||||
onLoeschen,
|
||||
onUmbenennen,
|
||||
}: {
|
||||
item: MediaItem
|
||||
/** Was danach kaeme — gebraucht fuer "weiter zum naechsten Stueck": nur
|
||||
@@ -49,6 +51,8 @@ export function Ansehen({
|
||||
onNachher: () => void
|
||||
onSchliessen: () => void
|
||||
onLoeschen: () => void
|
||||
/** Fehlt in Ansichten, in denen Umbenennen keinen Sinn ergibt. */
|
||||
onUmbenennen?: () => void
|
||||
}) {
|
||||
const note = useStore((s) => s.note)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
@@ -174,6 +178,18 @@ export function Ansehen({
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
|
||||
{onUmbenennen && (
|
||||
<button
|
||||
onClick={onUmbenennen}
|
||||
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||
hover:bg-white/10 hover:text-schrift"
|
||||
aria-label="Umbenennen"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<PenLine size={13} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onLoeschen}
|
||||
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
|
||||
|
||||
@@ -104,6 +104,7 @@ export function Nachfrage({
|
||||
text,
|
||||
bestaetigung,
|
||||
wartet,
|
||||
gesperrt,
|
||||
onJa,
|
||||
onNein,
|
||||
}: {
|
||||
@@ -112,6 +113,11 @@ export function Nachfrage({
|
||||
text: ReactNode
|
||||
bestaetigung: string
|
||||
wartet?: boolean
|
||||
/** Sperrt den Bestaetigungsknopf, solange die Eingabe unvollstaendig ist.
|
||||
* Gebraucht, seit die Nachfrage auch Felder enthaelt (Umbenennen) — ein
|
||||
* Knopf, der bei leerem Feld gedrueckt werden kann, erzeugt einen Fehler,
|
||||
* den der Dialog vorher schon kannte. */
|
||||
gesperrt?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
}) {
|
||||
@@ -141,7 +147,13 @@ export function Nachfrage({
|
||||
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
|
||||
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
||||
<Knopf onClick={onNein} kind="Abbrechen" />
|
||||
<Knopf art="gefahr" wartet={wartet} onClick={onJa} kind={bestaetigung} />
|
||||
<Knopf
|
||||
art="gefahr"
|
||||
wartet={wartet}
|
||||
disabled={gesperrt}
|
||||
onClick={onJa}
|
||||
kind={bestaetigung}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Copy, ExternalLink, Link2, Share2, Trash2 } from 'lucide-react'
|
||||
import { api, type Folders, type FreigabeRow } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { zeit } from '../format'
|
||||
import { Karte, Knopf, Nachfrage, useKopieren } from './Bausteine'
|
||||
|
||||
/** Freigaben: ein Ordner zum Ansehen, ohne Anmeldung.
|
||||
*
|
||||
* WAS HIER WIRKLICH FREIGEGEBEN WIRD, steht groß dabei — sonst entsteht ein
|
||||
* gefährliches Missverständnis in beide Richtungen: die DATEIEN sind ohnehin
|
||||
* öffentlich (wer die Adresse eines Bildes kennt, bekommt es), neu ist nur die
|
||||
* LISTE. Und umgekehrt: einen Link zurückzuziehen macht die Bilder nicht
|
||||
* wieder privat.
|
||||
*
|
||||
* Wer das nicht weiß, zieht einen Link zurück und glaubt, damit sei etwas
|
||||
* verschwunden. */
|
||||
export function FreigabenKarte() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [items, setItems] = useState<FreigabeRow[] | null>(null)
|
||||
const [ordner, setOrdner] = useState<Folders | null>(null)
|
||||
const [wahl, setWahl] = useState('')
|
||||
const [titel, setTitel] = useState('')
|
||||
const [legtAn, setLegtAn] = useState(false)
|
||||
const [weg, setWeg] = useState<FreigabeRow | null>(null)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [f, o] = await Promise.all([api.freigaben(), api.folders()])
|
||||
setItems(f.items)
|
||||
setOrdner(o)
|
||||
if (!wahl && o.items[0]) setWahl(o.items[0].folder)
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
// Einmal beim Öffnen. Freigaben ändern sich nicht von selbst.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (!items) return null
|
||||
|
||||
return (
|
||||
<Karte titel="Freigaben">
|
||||
<p className="mb-3 text-xs text-schrift-leise">
|
||||
Ein Link auf einen Ordner, den jeder ohne Anmeldung ansehen kann — etwa ein
|
||||
Fahrzeugkatalog für Spieler.{' '}
|
||||
<strong className="text-schrift">
|
||||
Freigegeben wird die Liste, nicht der Inhalt:
|
||||
</strong>{' '}
|
||||
die Dateien selbst sind ohnehin öffentlich, wer eine Adresse kennt, bekommt das
|
||||
Bild. Ein zurückgezogener Link macht sie deshalb nicht wieder privat — er nimmt
|
||||
nur die Übersicht weg.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-40 flex-1">
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Ordner
|
||||
</span>
|
||||
<select
|
||||
value={wahl}
|
||||
onChange={(e) => setWahl(e.target.value)}
|
||||
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
|
||||
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
|
||||
>
|
||||
{(ordner?.items ?? []).map((o) => (
|
||||
<option key={o.folder} value={o.folder}>
|
||||
{' '.repeat(o.tiefe * 2)}
|
||||
{o.folder.split('/').pop()} ({o.files})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-40 flex-1">
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Überschrift (freiwillig)
|
||||
</span>
|
||||
<input
|
||||
value={titel}
|
||||
onChange={(e) => setTitel(e.target.value)}
|
||||
placeholder="Fahrzeugkatalog"
|
||||
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5 text-sm
|
||||
text-schrift placeholder:text-schrift-leise/60
|
||||
focus:border-akzent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Knopf
|
||||
art="haupt"
|
||||
wartet={legtAn}
|
||||
disabled={!wahl}
|
||||
onClick={async () => {
|
||||
setLegtAn(true)
|
||||
const r = await run(
|
||||
() => api.freigabeAnlegen(wahl, titel),
|
||||
(x) => `Link für ${x.ordner} steht (${x.dateien} Dateien).`,
|
||||
)
|
||||
setLegtAn(false)
|
||||
if (r) {
|
||||
setTitel('')
|
||||
void laden()
|
||||
}
|
||||
}}
|
||||
kind={
|
||||
<>
|
||||
<Share2 size={15} />
|
||||
Link erzeugen
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-schrift-leise">Noch keine Freigabe.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="flex flex-wrap 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-40 shrink-0 truncate text-sm" title={f.ordner}>
|
||||
{f.titel || f.ordner}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-schrift-leise">
|
||||
{f.url}
|
||||
</code>
|
||||
|
||||
{/* Wird der Link noch benutzt? Genau die Frage, die
|
||||
man sich stellt, bevor man ihn wegwirft. */}
|
||||
<span
|
||||
className="shrink-0 text-[11px] text-schrift-leise tabular-nums"
|
||||
title={f.zuletzt ? `zuletzt ${zeit(f.zuletzt)}` : 'nie geöffnet'}
|
||||
>
|
||||
{f.aufrufe}× geöffnet
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await kopieren(f.url, String(f.id))))
|
||||
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||
}}
|
||||
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="Link kopieren"
|
||||
>
|
||||
{kopiert === String(f.id) ? (
|
||||
<Check size={13} className="text-gut" />
|
||||
) : (
|
||||
<Copy size={13} />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="Öffnen"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setWeg(f)}
|
||||
className="shrink-0 rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-schlecht/15 hover:text-schlecht"
|
||||
aria-label="Zurückziehen"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Nachfrage
|
||||
offen={weg !== null}
|
||||
titel="Link zurückziehen?"
|
||||
bestaetigung="Zurückziehen"
|
||||
onNein={() => setWeg(null)}
|
||||
onJa={async () => {
|
||||
const f = weg
|
||||
setWeg(null)
|
||||
if (!f) return
|
||||
await run(() => api.freigabeWeg(f.id), 'Link zurückgezogen.')
|
||||
void laden()
|
||||
}}
|
||||
text={
|
||||
<p>
|
||||
Der Link auf <code className="font-mono">{weg?.ordner}</code> führt danach
|
||||
ins Leere.{' '}
|
||||
<strong className="text-schrift">
|
||||
Die Dateien bleiben öffentlich erreichbar
|
||||
</strong>{' '}
|
||||
— wer eine ihrer Adressen kennt oder gespeichert hat, kommt weiterhin
|
||||
heran. Weg ist nur die Übersicht.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Folder, FolderOpen, FolderPlus, Layers, X } from 'lucide-react'
|
||||
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, X } from 'lucide-react'
|
||||
import { api, type Folders } from '../api'
|
||||
import { bytes } from '../format'
|
||||
import { run } from '../store'
|
||||
@@ -21,12 +21,14 @@ export function Ordner({
|
||||
gewaehlt,
|
||||
onWahl,
|
||||
onAngelegt,
|
||||
onUmbenennen,
|
||||
}: {
|
||||
daten: Folders | null
|
||||
gewaehlt: string
|
||||
onWahl: (ordner: string) => void
|
||||
/** Damit die Liste den neuen Ordner auch zeigt. */
|
||||
onAngelegt: () => void
|
||||
onUmbenennen: (pfad: string) => void
|
||||
}) {
|
||||
const [neu, setNeu] = useState<string | null>(null)
|
||||
const [legtAn, setLegtAn] = useState(false)
|
||||
@@ -57,11 +59,11 @@ export function Ordner({
|
||||
groesse: number,
|
||||
tiefe = 0,
|
||||
onClick?: () => void,
|
||||
pfad?: string,
|
||||
) => (
|
||||
<button
|
||||
<div
|
||||
key={name + tiefe}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left text-sm
|
||||
className={`group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm
|
||||
transition-colors ${
|
||||
aktiv
|
||||
? 'bg-flaeche-hoch text-schrift'
|
||||
@@ -69,17 +71,38 @@ export function Ordner({
|
||||
}`}
|
||||
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
|
||||
>
|
||||
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
||||
<span className="flex-1 truncate font-mono text-xs" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<button onClick={onClick} className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Erst beim Darueberfahren. Eine Zeile mit drei Zahlen und einem
|
||||
dauerhaft sichtbaren Knopf waere Laerm — und bei einem Dutzend
|
||||
Ordnern zwoelfmal derselbe. */}
|
||||
{pfad !== undefined && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUmbenennen(pfad)
|
||||
}}
|
||||
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
||||
group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100"
|
||||
aria-label={`${pfad} umbenennen`}
|
||||
title="Umbenennen"
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
|
||||
{anzahl}
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
|
||||
{bytes(groesse)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -107,6 +130,7 @@ export function Ordner({
|
||||
o.bytes,
|
||||
o.tiefe,
|
||||
() => onWahl(o.folder),
|
||||
o.folder,
|
||||
),
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, PenLine } from 'lucide-react'
|
||||
import { api, type MediaItem } from '../api'
|
||||
import { run } from '../store'
|
||||
import { Feld, Nachfrage } from './Bausteine'
|
||||
|
||||
/** Eine Datei umbenennen.
|
||||
*
|
||||
* DER PFAD IST DIE ADRESSE — das ist der Satz, an dem dieses ganze Projekt
|
||||
* haengt, und beim Umbenennen ist er eine Warnung: die alte Adresse steht
|
||||
* womoeglich in einem Skript, das niemand mehr im Kopf hat, und sie meldet
|
||||
* sich nicht, wenn sie ins Leere zeigt.
|
||||
*
|
||||
* Deshalb steht die Zahl der ABRUFE gross daneben. Eine Datei, die noch nie
|
||||
* jemand geholt hat, umzubenennen ist harmlos; eine mit viertausend Abrufen
|
||||
* ist eine Entscheidung. Der Dienst ist die einzige Stelle, die den
|
||||
* Unterschied kennt — also sagt er ihn. */
|
||||
export function Umbenennen({
|
||||
datei,
|
||||
onAbbruch,
|
||||
onFertig,
|
||||
}: {
|
||||
datei: MediaItem
|
||||
onAbbruch: () => void
|
||||
onFertig: () => void
|
||||
}) {
|
||||
const alt = datei.path.split('/').pop() ?? datei.path
|
||||
const ordner = datei.path.split('/').slice(0, -1).join('/')
|
||||
const [name, setName] = useState(alt)
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
|
||||
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||
|
||||
// Die Endung mitzuaendern ist erlaubt, aber selten gewollt: sie entscheidet
|
||||
// ueber den Dateityp, den der Dienst meldet.
|
||||
const endungAlt = alt.includes('.') ? alt.split('.').pop() : ''
|
||||
const endungNeu = name.includes('.') ? name.split('.').pop() : ''
|
||||
const endungWeg = endungAlt !== endungNeu
|
||||
|
||||
return (
|
||||
<Nachfrage
|
||||
offen
|
||||
titel="Umbenennen"
|
||||
bestaetigung="Umbenennen"
|
||||
wartet={laeuft}
|
||||
gesperrt={!geaendert}
|
||||
onNein={onAbbruch}
|
||||
onJa={async () => {
|
||||
setLaeuft(true)
|
||||
const r = await run(
|
||||
() => api.umbenennen(datei.id, name.trim()),
|
||||
(x) => `Heisst jetzt ${x.neu}.`,
|
||||
)
|
||||
setLaeuft(false)
|
||||
if (r) onFertig()
|
||||
}}
|
||||
text={
|
||||
<div className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Neuer Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Der Ordner bleibt:{' '}
|
||||
<code className="font-mono">{ordner ? `${ordner}/` : '(Wurzel)'}</code>
|
||||
</p>
|
||||
|
||||
{/* Was daraus wird, STEHT DA — vollstaendig und zum Lesen.
|
||||
Wer eine Adresse aendert, soll die neue sehen, bevor er
|
||||
drueckt, und nicht danach. */}
|
||||
{geaendert && (
|
||||
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||
{ordner ? `${ordner}/` : ''}
|
||||
<span className="text-akzent">{name.trim()}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{datei.abrufe > 0 && (
|
||||
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Diese Datei wurde <strong>{datei.abrufe}×</strong> geholt. Die
|
||||
alte Adresse gibt danach 404 — wer sie in einem Skript stehen
|
||||
hat, merkt es erst, wenn das Bild fehlt.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{geaendert && endungWeg && (
|
||||
<p className="flex items-start gap-2 text-xs text-schrift-leise">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />
|
||||
<span>
|
||||
Die Endung aendert sich von{' '}
|
||||
<code className="font-mono">.{endungAlt || '—'}</code> auf{' '}
|
||||
<code className="font-mono">.{endungNeu || '—'}</code>. Damit
|
||||
aendert sich auch der Typ, den der Dienst meldet.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Einen Ordner umbenennen — derselbe Vorgang, anderer Hebel.
|
||||
*
|
||||
* Hier aendern sich auf einen Schlag ALLE Adressen darin. Deshalb steht die
|
||||
* Zahl der betroffenen Dateien in der Nachfrage, und zwar bevor gedrueckt
|
||||
* wird. */
|
||||
export function OrdnerUmbenennen({
|
||||
pfad,
|
||||
dateien,
|
||||
onAbbruch,
|
||||
onFertig,
|
||||
}: {
|
||||
pfad: string
|
||||
dateien: number
|
||||
onAbbruch: () => void
|
||||
onFertig: (neu: string) => void
|
||||
}) {
|
||||
const alt = pfad.split('/').pop() ?? pfad
|
||||
const oben = pfad.split('/').slice(0, -1).join('/')
|
||||
const [name, setName] = useState(alt)
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
const geaendert = name.trim() !== alt && name.trim().length > 0
|
||||
|
||||
return (
|
||||
<Nachfrage
|
||||
offen
|
||||
titel="Ordner umbenennen"
|
||||
bestaetigung="Umbenennen"
|
||||
wartet={laeuft}
|
||||
gesperrt={!geaendert}
|
||||
onNein={onAbbruch}
|
||||
onJa={async () => {
|
||||
setLaeuft(true)
|
||||
const r = await run(
|
||||
() => api.ordnerUmbenennen(pfad, name.trim()),
|
||||
(x) => `${x.alt} heisst jetzt ${x.neu} (${x.dateien} Dateien).`,
|
||||
)
|
||||
setLaeuft(false)
|
||||
if (r) onFertig(r.neu)
|
||||
}}
|
||||
text={
|
||||
<div className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Neuer Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
/>
|
||||
|
||||
{geaendert && (
|
||||
<p className="rounded border border-rand bg-grund px-3 py-2 font-mono text-xs break-all">
|
||||
{oben ? `${oben}/` : ''}
|
||||
<span className="text-akzent">{name.trim()}</span>/
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dateien > 0 && (
|
||||
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<strong>{dateien} Dateien</strong> bekommen damit eine neue
|
||||
Adresse. Jede alte gibt danach 404 — und keine davon meldet sich
|
||||
von selbst.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const UmbenennenZeichen = PenLine
|
||||
@@ -6,6 +6,7 @@ import { bytes, zeit } from '../format'
|
||||
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||
import { DiscordKarte } from '../components/DiscordKarte'
|
||||
import { VorlagenKarte } from '../components/VorlagenKarte'
|
||||
import { FreigabenKarte } from '../components/FreigabenKarte'
|
||||
import { MeldungenKarte } from '../components/MeldungenKarte'
|
||||
|
||||
/** Alles, was sich einstellen lässt, an einer Stelle.
|
||||
@@ -28,6 +29,8 @@ export function Einstellungen() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
|
||||
|
||||
<FreigabenKarte />
|
||||
<SicherungKarte />
|
||||
<MeldungenKarte />
|
||||
<DiscordKarte />
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FileQuestion, Film, Image, Loader2, Music, Play } from 'lucide-react'
|
||||
import { bytes, dauerText, istBild } from '../format'
|
||||
|
||||
/** Ein freigegebener Ordner — ohne Anmeldung.
|
||||
*
|
||||
* DIESE SEITE STEHT AUSSERHALB der angemeldeten Oberfläche und weiß nichts von
|
||||
* ihr: kein Store, keine Reiter, kein Kontomenü. Wer hierherkommt, hat einen
|
||||
* Link bekommen und will Bilder sehen.
|
||||
*
|
||||
* Sie zeigt ABSICHTLICH weniger als die Galerie: keine Pfade, keine Zeitpunkte,
|
||||
* keine Abrufzahlen, keine Knöpfe. Was ein Katalog braucht, ist das Bild, der
|
||||
* Name und die Adresse zum Kopieren. */
|
||||
|
||||
type Eintrag = {
|
||||
name: string
|
||||
size: number
|
||||
mime: string
|
||||
dauer: number | null
|
||||
url: string
|
||||
thumb: string
|
||||
}
|
||||
|
||||
type Seite = { titel: string; ordner: string; anzahl: number; items: Eintrag[] }
|
||||
|
||||
export function Freigabe({ schluessel }: { schluessel: string }) {
|
||||
const [daten, setDaten] = useState<Seite | null>(null)
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [gross, setGross] = useState<Eintrag | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
fetch(`/api/s/${encodeURIComponent(schluessel)}`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(r.status === 404 ? 'nicht gefunden' : `HTTP ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((d) => lebt && setDaten(d))
|
||||
.catch((e) => lebt && setFehler(e instanceof Error ? e.message : 'unbekannt'))
|
||||
return () => {
|
||||
lebt = false
|
||||
}
|
||||
}, [schluessel])
|
||||
|
||||
if (fehler) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-grund p-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<FileQuestion size={34} className="mx-auto text-schrift-leise/50" />
|
||||
<p className="mt-3 text-sm text-schrift">Diesen Link gibt es nicht.</p>
|
||||
<p className="mt-1 text-xs text-schrift-leise">
|
||||
Er wurde zurückgezogen, oder er war nie einer. Beides sieht von hier
|
||||
gleich aus — und das ist Absicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!daten) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-grund">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-grund">
|
||||
<header className="border-b border-rand bg-flaeche">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-4">
|
||||
<h1 className="text-lg font-semibold text-schrift">{daten.titel}</h1>
|
||||
<p className="mt-0.5 text-xs text-schrift-leise">
|
||||
{daten.anzahl} {daten.anzahl === 1 ? 'Datei' : 'Dateien'} ·{' '}
|
||||
{bytes(daten.items.reduce((s, i) => s + i.size, 0))}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-5">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
|
||||
{daten.items.map((i) => (
|
||||
<figure
|
||||
key={i.url}
|
||||
className="overflow-hidden rounded-lg border border-rand bg-flaeche"
|
||||
>
|
||||
<button
|
||||
onClick={() => setGross(i)}
|
||||
className="block w-full"
|
||||
aria-label={`${i.name} groß ansehen`}
|
||||
>
|
||||
<div className="relative flex aspect-square items-center justify-center bg-grund">
|
||||
<Kachel eintrag={i} />
|
||||
</div>
|
||||
</button>
|
||||
<figcaption className="border-t border-rand p-2">
|
||||
<p
|
||||
className="truncate font-mono text-[11px] text-schrift"
|
||||
title={i.name}
|
||||
>
|
||||
{i.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-schrift-leise">{bytes(i.size)}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-schrift-leise">
|
||||
Bereitgestellt mit d4rk_media
|
||||
</p>
|
||||
</main>
|
||||
|
||||
{gross && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 p-4"
|
||||
onClick={() => setGross(null)}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} className="max-h-full">
|
||||
{gross.mime.startsWith('video') ? (
|
||||
<video
|
||||
src={gross.url}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-[80vh] max-w-full rounded"
|
||||
/>
|
||||
) : gross.mime.startsWith('audio') ? (
|
||||
<audio src={gross.url} controls autoPlay className="w-80" />
|
||||
) : (
|
||||
<img
|
||||
src={gross.url}
|
||||
alt={gross.name}
|
||||
className="max-h-[80vh] max-w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-schrift-leise">{gross.name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dieselbe Regel wie in der Galerie: erst die Vorschau, dann ein Zeichen, das
|
||||
* die Art benennt. Ein Fragezeichen auf einer gesunden MP4 sieht aus wie ein
|
||||
* Fehler — und hier sieht es ein Gast. */
|
||||
function Kachel({ eintrag }: { eintrag: Eintrag }) {
|
||||
const [weg, setWeg] = useState(false)
|
||||
const bewegt = eintrag.mime.startsWith('video') || eintrag.mime.startsWith('audio')
|
||||
|
||||
if (!weg) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={eintrag.thumb}
|
||||
onError={(e) => {
|
||||
const bild = e.currentTarget
|
||||
if (istBild(eintrag.mime) && !bild.dataset.gefallen) {
|
||||
bild.dataset.gefallen = 'ja'
|
||||
bild.src = eintrag.url
|
||||
return
|
||||
}
|
||||
setWeg(true)
|
||||
}}
|
||||
alt={eintrag.name}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
{bewegt && (
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="rounded-full bg-black/55 p-2">
|
||||
<Play size={14} className="translate-x-[1px] fill-white text-white" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{eintrag.dauer !== null && eintrag.dauer > 0 && (
|
||||
<span className="pointer-events-none absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[11px] tabular-nums text-white">
|
||||
{dauerText(eintrag.dauer)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Zeichen = eintrag.mime.startsWith('video')
|
||||
? Film
|
||||
: eintrag.mime.startsWith('audio')
|
||||
? Music
|
||||
: istBild(eintrag.mime)
|
||||
? Image
|
||||
: FileQuestion
|
||||
return <Zeichen size={26} className="text-schrift-leise/40" />
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { Ordner } from '../components/Ordner'
|
||||
import { Hochladen } from '../components/Hochladen'
|
||||
import { Ansehen } from '../components/Ansehen'
|
||||
import { Verschieben } from '../components/Verschieben'
|
||||
import { OrdnerUmbenennen, Umbenennen } from '../components/Umbenennen'
|
||||
import { useAuffrischen } from '../auffrischen'
|
||||
import { useMerker } from '../merker'
|
||||
|
||||
@@ -65,6 +66,8 @@ export function Galerie() {
|
||||
// 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 [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
|
||||
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
||||
@@ -240,6 +243,7 @@ export function Galerie() {
|
||||
daten={ordnerDaten}
|
||||
gewaehlt={ordner}
|
||||
onAngelegt={() => void ordnerLaden()}
|
||||
onUmbenennen={setOrdnerUmbenennen}
|
||||
onWahl={(o) => {
|
||||
setOrdner(o)
|
||||
setSeite(0)
|
||||
@@ -370,6 +374,12 @@ export function Galerie() {
|
||||
<option value="angelegt">zuerst hochgeladen</option>
|
||||
<option value="pfad">Pfad</option>
|
||||
<option value="groesse">Größe</option>
|
||||
{/* Gezählt wurde längst — ordnen konnte man danach
|
||||
nicht, und damit beantwortete die Zahl die Frage
|
||||
„was wird eigentlich gebraucht" nur für die zehn
|
||||
im Speicherbericht. */}
|
||||
<option value="abrufe">Abrufe</option>
|
||||
<option value="geholt">zuletzt geholt</option>
|
||||
</select>
|
||||
<select
|
||||
value={proSeite}
|
||||
@@ -552,7 +562,23 @@ export function Galerie() {
|
||||
{item.path}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
||||
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||
{/* Wonach sortiert wird, STEHT AUCH DA.
|
||||
Nach einer Zahl zu ordnen, die man
|
||||
nirgends sieht, ist eine Reihenfolge
|
||||
ohne Begründung — man sieht nur, dass
|
||||
sich etwas geändert hat. */}
|
||||
{sortierung === 'abrufe' || sortierung === 'geholt' ? (
|
||||
<>
|
||||
{item.abrufe}× geholt ·{' '}
|
||||
{item.zuletzt_abgerufen
|
||||
? zeit(item.zuletzt_abgerufen)
|
||||
: 'nie'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1">
|
||||
@@ -642,10 +668,45 @@ export function Galerie() {
|
||||
setAnsehen(null)
|
||||
setLoeschen([item])
|
||||
}}
|
||||
onUmbenennen={() => {
|
||||
setAnsehen(null)
|
||||
setUmbenennen(item)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{umbenennen && (
|
||||
<Umbenennen
|
||||
datei={umbenennen}
|
||||
onAbbruch={() => setUmbenennen(null)}
|
||||
onFertig={() => {
|
||||
setUmbenennen(null)
|
||||
allesLaden()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ordnerUmbenennen !== null && (
|
||||
<OrdnerUmbenennen
|
||||
pfad={ordnerUmbenennen}
|
||||
dateien={
|
||||
ordnerDaten?.items.find((o) => o.folder === ordnerUmbenennen)?.files ?? 0
|
||||
}
|
||||
onAbbruch={() => setOrdnerUmbenennen(null)}
|
||||
onFertig={(neu) => {
|
||||
setOrdnerUmbenennen(null)
|
||||
// Steht die Ansicht IM umbenannten Ordner, muss sie
|
||||
// mitwandern -- sonst zeigt sie auf einen Pfad, den es
|
||||
// nicht mehr gibt, und der Inhalt ist leer.
|
||||
if (ordner === ordnerUmbenennen || ordner.startsWith(ordnerUmbenennen + '/')) {
|
||||
setOrdner(neu + ordner.slice(ordnerUmbenennen.length))
|
||||
}
|
||||
allesLaden()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verschieben && (
|
||||
<Verschieben
|
||||
dateien={verschieben}
|
||||
|
||||
@@ -21,13 +21,15 @@ export function Tokens() {
|
||||
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
||||
const [weg, setWeg] = useState<TokenRow | null>(null)
|
||||
const [loescht, setLoescht] = useState(false)
|
||||
const [ohneToken, setOhneToken] = useState<{ dateien: number; bytes: number } | null>(null)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
async function laden() {
|
||||
setLaedt(true)
|
||||
try {
|
||||
const { items } = await api.tokens()
|
||||
setRows(items)
|
||||
const antwort = await api.tokens()
|
||||
setRows(antwort.items)
|
||||
setOhneToken(antwort.ohneToken)
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||
} finally {
|
||||
@@ -212,6 +214,12 @@ export function Tokens() {
|
||||
<th className="pb-2 font-medium">Präfix</th>
|
||||
<th className="pb-2 font-medium">Rechte</th>
|
||||
<th className="pb-2 font-medium">Grenzen</th>
|
||||
{/* Was mit dem Token wirklich abgelegt wurde.
|
||||
`token_id` steht seit dem ersten Tag in jeder
|
||||
Zeile und war bis hierhin NIRGENDS zu sehen —
|
||||
dabei ist es die Auskunft, die vor dem
|
||||
Löschen zählt. */}
|
||||
<th className="pb-2 text-right font-medium">Abgelegt</th>
|
||||
<th className="pb-2 font-medium">Angelegt</th>
|
||||
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
||||
<th className="pb-2" />
|
||||
@@ -256,6 +264,23 @@ export function Tokens() {
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'}
|
||||
</td>
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
{t.dateien === 0 ? (
|
||||
<span className="text-xs text-schrift-leise">—</span>
|
||||
) : (
|
||||
<span title={
|
||||
t.zuletztAbgelegt
|
||||
? `zuletzt ${zeit(t.zuletztAbgelegt)}`
|
||||
: undefined
|
||||
}>
|
||||
<span className="tabular-nums">{t.dateien}</span>
|
||||
<span className="text-xs text-schrift-leise">
|
||||
{' '}
|
||||
· {bytes(t.bytes)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-schrift-leise">
|
||||
{zeit(t.created_at)}
|
||||
</td>
|
||||
@@ -277,6 +302,15 @@ export function Tokens() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{ohneToken && ohneToken.dateien > 0 && (
|
||||
<p className="mt-3 border-t border-rand pt-3 text-xs text-schrift-leise">
|
||||
Dazu kommen <strong className="text-schrift">{ohneToken.dateien}</strong>{' '}
|
||||
Dateien ({bytes(ohneToken.bytes)}) <strong>ohne</strong> Token — aus dem
|
||||
Dashboard hochgeladen. Dort gibt es eine Sitzung und keinen Token;
|
||||
ohne diese Zeile ginge die Summe nicht auf.
|
||||
</p>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{/* Der Klartext — genau einmal.
|
||||
|
||||
Reference in New Issue
Block a user