feat: das Dashboard -- und die eine Regel steht im Code, nicht im Vorsatz
Schritt 4. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide, Zustand; kein Konsta. Quelle in server/ui, Build nach server/web (gitignored), im Abbild von einer eigenen Docker-Stufe gebaut. JEDER KNOPF GIBT RUECKMELDUNG -- und zwar erzwungen, nicht vorgenommen: - api.ts haelt den EINZIGEN fetch und GENAU EINE Fehlerklasse. Der Fivemanage-Fehler war, zwei zu haben: geworfen wurde `new Error`, geprueft auf `instanceof ApiError`, und damit verschwand jeder Fehlschlag lautlos. - store.ts haelt run(): Aktion rein, Erfolgstext rein, und es meldet bei Erfolg diesen und bei Fehlschlag den Text DES DIENSTES. Wer darueber geht, kann keinen stillen Knopf bauen. - Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel. Der zweite Klick eine Sekunde spaeter war der Weg zu neunzehn gleichnamigen Organisationen. - Fehlermeldungen bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von allein verschwindet, ist einer, den niemand gelesen hat. @vitejs/plugin-react ist auf ^4 festgenagelt: die 6 verlangt vite ^8. In den peerDependencies nachgesehen -- Vite 5.4.21 liegt in der Schnittmenge von plugin-react@4 und @tailwindcss/vite@4. Am laufenden Container nachgemessen: / kommt als text/html, assets/*.js als text/javascript, *.css als text/css (die MIME-Falle aus Schritt 1 traegt), und /tokens, /speicher, /konto liefern dieselbe index.html -- die SPA-Rueckfall- route tut, wofuer sie gebaut wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d4rk_media</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="wurzel"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2825
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "d4rk-media-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Dashboard fuer d4rk_media",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.31.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HardDrive, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react'
|
||||
import { run, useStore } from './store'
|
||||
import { Anmelden } from './pages/Anmelden'
|
||||
import { Galerie } from './pages/Galerie'
|
||||
import { Speicher } from './pages/Speicher'
|
||||
import { Tokens } from './pages/Tokens'
|
||||
import { Konto } from './pages/Konto'
|
||||
import { Meldungen } from './components/Meldungen'
|
||||
|
||||
// Ein winziger Router über die History-API statt einer Bibliothek.
|
||||
//
|
||||
// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut
|
||||
// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein
|
||||
// Neuladen auf /tokens im Router landet und nicht im 404.
|
||||
|
||||
const seiten = [
|
||||
{ pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
||||
] as const
|
||||
|
||||
function usePfad(): [string, (p: string) => void] {
|
||||
const [pfad, setPfad] = useState(window.location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
const zurueck = () => setPfad(window.location.pathname)
|
||||
window.addEventListener('popstate', zurueck)
|
||||
return () => window.removeEventListener('popstate', zurueck)
|
||||
}, [])
|
||||
|
||||
return [
|
||||
pfad,
|
||||
(p: string) => {
|
||||
window.history.pushState(null, '', p)
|
||||
setPfad(p)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
|
||||
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
|
||||
// ganze Dashboard neu — bei 60 Kacheln mit Bildern ist das spürbar.
|
||||
const user = useStore((s) => s.user)
|
||||
const checking = useStore((s) => s.checking)
|
||||
const checkSession = useStore((s) => s.checkSession)
|
||||
const logout = useStore((s) => s.logout)
|
||||
const [pfad, gehZu] = usePfad()
|
||||
|
||||
useEffect(() => {
|
||||
void checkSession()
|
||||
}, [checkSession])
|
||||
|
||||
// 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.
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<HardDrive className="animate-pulse text-schrift-leise" />
|
||||
<Meldungen />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
<Anmelden />
|
||||
<Meldungen />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
|
||||
const Inhalt = aktiv.Inhalt
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-rand bg-flaeche/80 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-2.5">
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
gehZu('/')
|
||||
}}
|
||||
className="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<HardDrive size={18} className="text-akzent" />
|
||||
d4rk_media
|
||||
</a>
|
||||
|
||||
<nav className="flex flex-1 gap-1">
|
||||
{seiten.map((s) => {
|
||||
const an = s.pfad === aktiv.pfad
|
||||
return (
|
||||
<a
|
||||
key={s.pfad}
|
||||
href={s.pfad}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
gehZu(s.pfad)
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
|
||||
transition-colors ${
|
||||
an
|
||||
? 'bg-flaeche-hoch text-schrift'
|
||||
: 'text-schrift-leise hover:text-schrift'
|
||||
}`}
|
||||
>
|
||||
<s.kind size={15} />
|
||||
{s.name}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => void run(logout, 'Abgemeldet.')}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
|
||||
text-schrift-leise hover:text-schrift"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-5">
|
||||
<Inhalt />
|
||||
</main>
|
||||
|
||||
<Meldungen />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Der EINZIGE Ort, an dem fetch steht.
|
||||
//
|
||||
// Und die Stelle, an der der Fivemanage-Anlauf gescheitert ist: dessen
|
||||
// http-util.ts warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
|
||||
// `instanceof ApiError` — und damit wurde jeder Fehler lautlos verschluckt.
|
||||
// Deshalb gibt es hier genau eine Fehlerklasse, und `request` wirft
|
||||
// ausschließlich diese. Wer sie fängt, hat immer einen Text zum Anzeigen.
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** Kein Netz, kein Server, DNS tot — alles, was nie beim Dienst ankam. */
|
||||
export class NetworkError extends ApiError {
|
||||
constructor(message: string) {
|
||||
super(message, 0)
|
||||
this.name = 'NetworkError'
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
// Das Sitzungs-Cookie ist httpOnly; ohne diese Zeile schickt der
|
||||
// Browser es nicht mit, und jede Antwort wäre 401.
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: {
|
||||
...(init.body ? { 'content-type': 'application/json' } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// fetch wirft nur, wenn die Anfrage gar nicht stattgefunden hat. Das
|
||||
// ist ein anderer Fall als "der Dienst sagt nein", und er verdient
|
||||
// einen anderen Satz.
|
||||
throw new NetworkError(
|
||||
`Der Dienst ist nicht erreichbar (${err instanceof Error ? err.message : 'unbekannt'})`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
// Der Dienst antwortet auf allen API-Wegen mit JSON. Kommt hier
|
||||
// etwas anderes an, ist der Proxy davor oder die Sitzung schuld —
|
||||
// dann steht der Anfang der Antwort in der Meldung, statt dass
|
||||
// "unerwarteter Fehler" auf dem Bildschirm steht.
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
`HTTP ${response.status}: ${text.slice(0, 140)}`,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
throw new ApiError('Die Antwort war kein JSON', response.status)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
data && typeof data === 'object' && typeof (data as any).error === 'string'
|
||||
? (data as any).error
|
||||
: `HTTP ${response.status}`
|
||||
throw new ApiError(message, response.status)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
const get = <T,>(path: string) => request<T>(path)
|
||||
const post = <T,>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
const del = <T,>(path: string) => request<T>(path, { method: 'DELETE' })
|
||||
|
||||
// -- Die Formen, die der Dienst wirklich liefert -------------------------------
|
||||
//
|
||||
// Abgeschrieben aus routes/dash.ts, nicht erfunden. Wo der Server
|
||||
// Datenbankspalten durchreicht, heißen sie hier genauso (can_delete,
|
||||
// created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die
|
||||
// beim nächsten Feld vergessen wird.
|
||||
|
||||
export type User = { id: number; username: string }
|
||||
|
||||
export type MediaItem = {
|
||||
id: number
|
||||
path: string
|
||||
size: number
|
||||
sha256: string
|
||||
mime: string
|
||||
token_id: number | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MediaPage = {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: MediaItem[]
|
||||
}
|
||||
|
||||
export type Stats = {
|
||||
files: number
|
||||
bytes: number
|
||||
tokens: number
|
||||
folders: { folder: string; files: number; bytes: number }[]
|
||||
publicUrl: string
|
||||
}
|
||||
|
||||
export type TokenRow = {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
can_delete: number
|
||||
created_at: number
|
||||
last_used_at: number | null
|
||||
}
|
||||
|
||||
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
||||
* Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr zeigen. */
|
||||
export type NewToken = {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
canDelete: boolean
|
||||
token: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
post<{ user: User }>('/api/dash/auth/login', { username, password }),
|
||||
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
|
||||
me: () => get<{ user: User }>('/api/dash/auth/me'),
|
||||
changePassword: (current: string, next: string) =>
|
||||
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
|
||||
|
||||
media: (query: string, limit: number, offset: number) =>
|
||||
get<MediaPage>(
|
||||
`/api/dash/media?query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
|
||||
deleteMediaBulk: (ids: number[]) =>
|
||||
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
|
||||
|
||||
stats: () => get<Stats>('/api/dash/stats'),
|
||||
|
||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
||||
createToken: (name: string, prefix: string, canDelete: boolean) =>
|
||||
post<NewToken>('/api/dash/tokens', { name, prefix, canDelete }),
|
||||
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// -- Knopf ---------------------------------------------------------------------
|
||||
|
||||
type KnopfArt = 'haupt' | 'still' | 'gefahr'
|
||||
|
||||
const arten: Record<KnopfArt, string> = {
|
||||
haupt: 'bg-akzent text-grund hover:bg-akzent/85 border-akzent',
|
||||
still: 'bg-flaeche-hoch text-schrift hover:bg-rand border-rand',
|
||||
gefahr: 'bg-schlecht/15 text-schlecht hover:bg-schlecht/25 border-schlecht/40',
|
||||
}
|
||||
|
||||
/** Ein Knopf, der beim Arbeiten aussieht, als arbeite er.
|
||||
*
|
||||
* `wartet` sperrt ihn und zeigt den Kreisel. Ohne das ist der zweite Klick
|
||||
* einen Wimpernschlag später eine zweite Anfrage — bei "Organisation anlegen"
|
||||
* war genau das der Weg zu neunzehn gleichnamigen Einträgen. */
|
||||
export function Knopf({
|
||||
art = 'still',
|
||||
wartet = false,
|
||||
kind,
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: {
|
||||
art?: KnopfArt
|
||||
wartet?: boolean
|
||||
kind?: ReactNode
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
disabled={disabled || wartet}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md border px-3 py-1.5
|
||||
text-sm font-medium transition-colors disabled:cursor-not-allowed
|
||||
disabled:opacity-50 ${arten[art]} ${className}`}
|
||||
>
|
||||
{wartet && <Loader2 size={15} className="animate-spin" />}
|
||||
{kind}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Eingabefeld ---------------------------------------------------------------
|
||||
|
||||
export function Feld({
|
||||
label,
|
||||
hinweis,
|
||||
className = '',
|
||||
...rest
|
||||
}: { label?: string; hinweis?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && (
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">{label}</span>
|
||||
)}
|
||||
<input
|
||||
{...rest}
|
||||
className={`w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
|
||||
text-schrift placeholder:text-schrift-leise/60
|
||||
focus:border-akzent focus:outline-none ${className}`}
|
||||
/>
|
||||
{hinweis && <span className="mt-1 block text-xs text-schrift-leise">{hinweis}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Karte ---------------------------------------------------------------------
|
||||
|
||||
export function Karte({
|
||||
titel,
|
||||
rechts,
|
||||
children,
|
||||
}: {
|
||||
titel?: string
|
||||
rechts?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-rand bg-flaeche">
|
||||
{(titel || rechts) && (
|
||||
<header className="flex items-center justify-between gap-3 border-b border-rand px-4 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{titel}</h2>
|
||||
{rechts}
|
||||
</header>
|
||||
)}
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Nachfrage vor dem Löschen -------------------------------------------------
|
||||
|
||||
/** Ein Dialog, der sagt, WAS gelöscht wird — nicht "Sind Sie sicher?".
|
||||
*
|
||||
* Löschen ist die einzige Stelle hier, die sich nicht rückgängig machen
|
||||
* lässt: die Datei ist danach von der Platte weg, und es gibt keine
|
||||
* Sicherung, aus der sie zurückkäme. */
|
||||
export function Nachfrage({
|
||||
offen,
|
||||
titel,
|
||||
text,
|
||||
bestaetigung,
|
||||
wartet,
|
||||
onJa,
|
||||
onNein,
|
||||
}: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: ReactNode
|
||||
bestaetigung: string
|
||||
wartet?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
}) {
|
||||
// Escape schließt. Ein Dialog, aus dem man nur mit der Maus herauskommt,
|
||||
// ist ein Dialog, den man versehentlich bestätigt.
|
||||
useEffect(() => {
|
||||
if (!offen) return
|
||||
const ab = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onNein()
|
||||
}
|
||||
window.addEventListener('keydown', ab)
|
||||
return () => window.removeEventListener('keydown', ab)
|
||||
}, [offen, onNein])
|
||||
|
||||
if (!offen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onNein}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-rand bg-flaeche shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="border-b border-rand px-4 py-3 text-sm font-semibold">{titel}</h3>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Leerzustand ---------------------------------------------------------------
|
||||
|
||||
export function Leer({ text, kind }: { text: string; kind?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-schrift-leise">
|
||||
{kind}
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -- In die Zwischenablage -----------------------------------------------------
|
||||
|
||||
/** Kopieren MIT Rückmeldung. Ein Kopierknopf ohne sichtbare Antwort ist der
|
||||
* Urtyp des kaputt aussehenden Knopfes: man klickt zweimal, weil man nicht
|
||||
* weiß, ob der erste Klick etwas getan hat. */
|
||||
export function useKopieren() {
|
||||
const [kopiert, setKopiert] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (kopiert === null) return
|
||||
const t = setTimeout(() => setKopiert(null), 1500)
|
||||
return () => clearTimeout(t)
|
||||
}, [kopiert])
|
||||
|
||||
async function kopieren(text: string, marke: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setKopiert(marke)
|
||||
return true
|
||||
} catch {
|
||||
// navigator.clipboard gibt es nur im sicheren Kontext. Über https
|
||||
// ist das gegeben; wer den Dienst über eine nackte LAN-Adresse
|
||||
// aufruft, bekommt hier ein ehrliches Nein statt eines Knopfes,
|
||||
// der scheinbar nichts tut.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { kopiert, kopieren }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
|
||||
import { useStore, type NoteKind } from '../store'
|
||||
|
||||
const gestalt: Record<NoteKind, { farbe: string; kind: typeof Info }> = {
|
||||
ok: { farbe: 'border-gut/40 bg-gut/10 text-gut', kind: CheckCircle2 },
|
||||
fehler: { farbe: 'border-schlecht/40 bg-schlecht/10 text-schlecht', kind: AlertTriangle },
|
||||
hinweis: { farbe: 'border-akzent/40 bg-akzent/10 text-akzent', kind: Info },
|
||||
}
|
||||
|
||||
/** Die Rückmeldungen, unten rechts.
|
||||
*
|
||||
* Erfolge verschwinden nach vier Sekunden von allein, Fehler bleiben stehen,
|
||||
* bis jemand sie wegklickt — das entscheidet der Laden, nicht diese Ansicht. */
|
||||
export function Meldungen() {
|
||||
const notes = useStore((s) => s.notes)
|
||||
const dismiss = useStore((s) => s.dismiss)
|
||||
|
||||
if (notes.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-4 bottom-4 z-60 flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{notes.map((n) => {
|
||||
const { farbe, kind: Kind } = gestalt[n.kind]
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm
|
||||
shadow-lg backdrop-blur ${farbe}`}
|
||||
>
|
||||
<Kind size={16} className="mt-0.5 shrink-0" />
|
||||
<span className="flex-1 break-words text-schrift">{n.text}</span>
|
||||
<button
|
||||
onClick={() => dismiss(n.id)}
|
||||
className="shrink-0 rounded p-0.5 text-schrift-leise hover:text-schrift"
|
||||
aria-label="Meldung schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Bytes so, wie ein Mensch sie liest. 1024er-Stufen, weil der Rest der
|
||||
* Werkzeugkette (du, ls, Explorer) das auch tut. */
|
||||
export function bytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
const stufen = ['KB', 'MB', 'GB', 'TB']
|
||||
let wert = n / 1024
|
||||
let i = 0
|
||||
while (wert >= 1024 && i < stufen.length - 1) {
|
||||
wert /= 1024
|
||||
i++
|
||||
}
|
||||
return `${wert.toFixed(wert < 10 ? 1 : 0)} ${stufen[i]}`
|
||||
}
|
||||
|
||||
/** Zeitstempel des Dienstes sind Millisekunden aus Date.now(). */
|
||||
export function zeit(ms: number | null): string {
|
||||
if (!ms) return '—'
|
||||
return new Date(ms).toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export const istBild = (mime: string) => mime.startsWith('image/')
|
||||
@@ -0,0 +1,48 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Tailwind 4 ist CSS-first: die Gestaltung steht hier, nicht in einer
|
||||
tailwind.config.js. Was unter @theme steht, wird zu Hilfsklassen — aus
|
||||
--color-grund wird bg-grund, text-grund, border-grund. */
|
||||
@theme {
|
||||
--color-grund: #0b0d10;
|
||||
--color-flaeche: #14181d;
|
||||
--color-flaeche-hoch: #1c222a;
|
||||
--color-rand: #2a323c;
|
||||
--color-schrift: #e6eaf0;
|
||||
--color-schrift-leise: #93a1b1;
|
||||
|
||||
--color-akzent: #4ea3ff;
|
||||
--color-akzent-tief: #1b4f85;
|
||||
|
||||
--color-gut: #3fb950;
|
||||
--color-schlecht: #f85149;
|
||||
--color-warn: #d29922;
|
||||
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#wurzel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-grund);
|
||||
color: var(--color-schrift);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Der Fokusrahmen bleibt sichtbar. Ein Dashboard, das sich nur mit der Maus
|
||||
bedienen lässt, ist bei 900 Kacheln kein Vergnügen. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-akzent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-akzent-tief);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
const wurzel = document.getElementById('wurzel')
|
||||
if (!wurzel) throw new Error('Der Aufhängepunkt #wurzel fehlt in index.html')
|
||||
|
||||
createRoot(wurzel).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { HardDrive } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
import { Feld, Knopf } from '../components/Bausteine'
|
||||
|
||||
export function Anmelden() {
|
||||
const login = useStore((s) => s.login)
|
||||
const note = useStore((s) => s.note)
|
||||
const [name, setName] = useState('admin')
|
||||
const [passwort, setPasswort] = useState('')
|
||||
const [wartet, setWartet] = useState(false)
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
|
||||
async function absenden(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFehler(null)
|
||||
setWartet(true)
|
||||
try {
|
||||
await login(name, passwort)
|
||||
} catch (err) {
|
||||
// Der Fehler steht AM FORMULAR und nicht nur in einer Meldung, die
|
||||
// nach vier Sekunden geht: hier schaut man hin, wenn etwas nicht
|
||||
// klappt. Der Text kommt vom Dienst ("Benutzername oder Passwort
|
||||
// stimmt nicht"), nicht von hier.
|
||||
const text = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||
setFehler(text)
|
||||
note('fehler', text)
|
||||
} finally {
|
||||
setWartet(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<form
|
||||
onSubmit={absenden}
|
||||
className="w-full max-w-sm rounded-lg border border-rand bg-flaeche p-6"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<HardDrive size={20} className="text-akzent" />
|
||||
<h1 className="text-lg font-semibold">d4rk_media</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Benutzername"
|
||||
value={name}
|
||||
autoComplete="username"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={passwort}
|
||||
autoComplete="current-password"
|
||||
onChange={(e) => setPasswort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fehler && (
|
||||
<p className="mt-3 rounded-md border border-schlecht/40 bg-schlecht/10 px-3 py-2 text-sm text-schlecht">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Knopf
|
||||
art="haupt"
|
||||
type="submit"
|
||||
wartet={wartet}
|
||||
disabled={!name || !passwort}
|
||||
className="mt-5 w-full"
|
||||
kind="Anmelden"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { api, type MediaItem, type MediaPage } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { bytes, istBild, zeit } from '../format'
|
||||
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
|
||||
const PRO_SEITE = 60
|
||||
|
||||
export function Galerie() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [suche, setSuche] = useState('')
|
||||
const [seite, setSeite] = useState(0)
|
||||
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null)
|
||||
const [loescht, setLoescht] = useState(false)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
||||
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
|
||||
// ankommen können.
|
||||
const [sucheAktiv, setSucheAktiv] = useState('')
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setSucheAktiv(suche)
|
||||
setSeite(0)
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [suche])
|
||||
|
||||
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
|
||||
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
|
||||
// Ergebnis von vorletzter Eingabe auf dem Schirm.
|
||||
const lauf = useRef(0)
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
const meine = ++lauf.current
|
||||
setLaedt(true)
|
||||
try {
|
||||
const seiteDaten = await api.media(sucheAktiv, PRO_SEITE, seite * PRO_SEITE)
|
||||
if (meine !== lauf.current) return
|
||||
setDaten(seiteDaten)
|
||||
} catch (err) {
|
||||
if (meine !== lauf.current) return
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
if (meine === lauf.current) setLaedt(false)
|
||||
}
|
||||
}, [sucheAktiv, seite, note])
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
}, [laden])
|
||||
|
||||
const items = daten?.items ?? []
|
||||
const gesamt = daten?.total ?? 0
|
||||
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
|
||||
|
||||
function umschalten(id: number) {
|
||||
setGewaehlt((alt) => {
|
||||
const neu = new Set(alt)
|
||||
if (neu.has(id)) neu.delete(id)
|
||||
else neu.add(id)
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
|
||||
|
||||
function alleUmschalten() {
|
||||
setGewaehlt((alt) => {
|
||||
const neu = new Set(alt)
|
||||
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
|
||||
else items.forEach((i) => neu.add(i.id))
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
async function loeschenAusfuehren() {
|
||||
if (!loeschen) return
|
||||
setLoescht(true)
|
||||
|
||||
const ergebnis =
|
||||
loeschen.length === 1
|
||||
? await run(
|
||||
() => api.deleteMedia(loeschen[0]!.id),
|
||||
(r) => `${r.deleted} gelöscht.`,
|
||||
)
|
||||
: await run(
|
||||
() => api.deleteMediaBulk(loeschen.map((i) => i.id)),
|
||||
(r) =>
|
||||
r.count === loeschen.length
|
||||
? `${r.count} Dateien gelöscht.`
|
||||
: // Der Dienst überspringt, was er nicht findet.
|
||||
// Diese Lücke zu verschweigen wäre genau die
|
||||
// Sorte Rückmeldung, die hier nicht vorkommt.
|
||||
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`,
|
||||
)
|
||||
|
||||
setLoescht(false)
|
||||
setLoeschen(null)
|
||||
if (ergebnis) {
|
||||
setGewaehlt(new Set())
|
||||
void laden()
|
||||
}
|
||||
}
|
||||
|
||||
async function adresseKopieren(item: MediaItem) {
|
||||
const ok = await kopieren(item.url, String(item.id))
|
||||
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Kopfzeile: Suche, Auswahl, Löschen */}
|
||||
<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/"
|
||||
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>
|
||||
|
||||
<span className="text-sm text-schrift-leise">
|
||||
{laedt ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
|
||||
{sucheAktiv && ' gefunden'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{items.length > 0 && (
|
||||
<Knopf
|
||||
onClick={alleUmschalten}
|
||||
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gewaehlt.size > 0 && (
|
||||
<Knopf
|
||||
art="gefahr"
|
||||
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}
|
||||
kind={
|
||||
<>
|
||||
<Trash2 size={15} />
|
||||
{gewaehlt.size} löschen
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Das Raster */}
|
||||
{items.length === 0 && !laedt ? (
|
||||
<Leer
|
||||
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
|
||||
text={
|
||||
sucheAktiv
|
||||
? `Nichts gefunden für "${sucheAktiv}".`
|
||||
: 'Noch keine Datei hochgeladen.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{items.map((item) => {
|
||||
const an = gewaehlt.has(item.id)
|
||||
return (
|
||||
<figure
|
||||
key={item.id}
|
||||
className={`group relative overflow-hidden rounded-lg border bg-flaeche
|
||||
transition-colors ${an ? 'border-akzent' : 'border-rand'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => umschalten(item.id)}
|
||||
className="block w-full"
|
||||
aria-pressed={an}
|
||||
aria-label={`${item.path} auswählen`}
|
||||
>
|
||||
<div className="flex aspect-square items-center justify-center bg-grund">
|
||||
{istBild(item.mime) ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.path}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FileQuestion
|
||||
size={28}
|
||||
className="text-schrift-leise/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{an && (
|
||||
<span className="pointer-events-none absolute top-2 left-2 rounded bg-akzent p-1 text-grund">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<figcaption className="border-t border-rand p-2">
|
||||
<p
|
||||
className="truncate font-mono text-xs text-schrift"
|
||||
title={item.path}
|
||||
>
|
||||
{item.path}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
||||
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => void adresseKopieren(item)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded border
|
||||
border-rand py-1 text-[11px] text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
>
|
||||
{kopiert === String(item.id) ? (
|
||||
<>
|
||||
<Check size={11} className="text-gut" />
|
||||
kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Adresse
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="In neuem Tab öffnen"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setLoeschen([item])}
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-schlecht/15 hover:text-schlecht"
|
||||
aria-label={`${item.path} löschen`}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blättern */}
|
||||
{seiten > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Knopf
|
||||
disabled={seite === 0}
|
||||
onClick={() => setSeite((s) => s - 1)}
|
||||
kind={<ChevronLeft size={15} />}
|
||||
/>
|
||||
<span className="text-sm text-schrift-leise">
|
||||
Seite {seite + 1} von {seiten}
|
||||
</span>
|
||||
<Knopf
|
||||
disabled={seite + 1 >= seiten}
|
||||
onClick={() => setSeite((s) => s + 1)}
|
||||
kind={<ChevronRight size={15} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Nachfrage
|
||||
offen={loeschen !== null}
|
||||
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
|
||||
bestaetigung="Endgültig löschen"
|
||||
wartet={loescht}
|
||||
onNein={() => setLoeschen(null)}
|
||||
onJa={() => void loeschenAusfuehren()}
|
||||
text={
|
||||
<>
|
||||
<p className="mb-2">
|
||||
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus
|
||||
der sie zurückkommt.
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
|
||||
{loeschen?.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
|
||||
{loeschen && loeschen.length > 12 && (
|
||||
<li className="text-schrift-leise">
|
||||
… und {loeschen.length - 12} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { api } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||
|
||||
export function Konto() {
|
||||
const user = useStore((s) => s.user)
|
||||
const note = useStore((s) => s.note)
|
||||
const [aktuell, setAktuell] = useState('')
|
||||
const [neu, setNeu] = useState('')
|
||||
const [wieder, setWieder] = useState('')
|
||||
const [wartet, setWartet] = useState(false)
|
||||
|
||||
const zuKurz = neu.length > 0 && neu.length < 8
|
||||
const ungleich = wieder.length > 0 && neu !== wieder
|
||||
|
||||
async function wechseln(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
// Die Wiederholung prüft nur diese Seite — der Dienst kennt sie nicht.
|
||||
// Deshalb hier ein eigener Satz statt einer Anfrage, die nichts
|
||||
// bewirken kann.
|
||||
if (neu !== wieder) {
|
||||
note('fehler', 'Die beiden neuen Passwörter stimmen nicht überein.')
|
||||
return
|
||||
}
|
||||
setWartet(true)
|
||||
const ok = await run(
|
||||
() => api.changePassword(aktuell, neu),
|
||||
'Passwort geändert. Alle anderen Anmeldungen wurden beendet.',
|
||||
)
|
||||
setWartet(false)
|
||||
if (ok) {
|
||||
setAktuell('')
|
||||
setNeu('')
|
||||
setWieder('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<Karte titel={`Angemeldet als ${user?.username ?? '—'}`}>
|
||||
<form onSubmit={wechseln} className="flex flex-col gap-3">
|
||||
<Feld
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={aktuell}
|
||||
onChange={(e) => setAktuell(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={neu}
|
||||
onChange={(e) => setNeu(e.target.value)}
|
||||
hinweis={zuKurz ? 'Mindestens 8 Zeichen.' : undefined}
|
||||
/>
|
||||
<Feld
|
||||
label="Neues Passwort wiederholen"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={wieder}
|
||||
onChange={(e) => setWieder(e.target.value)}
|
||||
hinweis={ungleich ? 'Stimmt nicht mit dem neuen Passwort überein.' : undefined}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Ein Wechsel beendet alle anderen Sitzungen. Diese hier bleibt bestehen.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Knopf
|
||||
art="haupt"
|
||||
type="submit"
|
||||
wartet={wartet}
|
||||
disabled={!aktuell || neu.length < 8 || neu !== wieder}
|
||||
kind="Passwort ändern"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Karte>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Copy, Folder, HardDrive, KeyRound, Loader2 } from 'lucide-react'
|
||||
import { api, type Stats } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { bytes } from '../format'
|
||||
import { Karte, Leer, useKopieren } from '../components/Bausteine'
|
||||
|
||||
export function Speicher() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
useEffect(() => {
|
||||
let lebt = true
|
||||
api.stats()
|
||||
.then((s) => lebt && setStats(s))
|
||||
.catch(
|
||||
(err) =>
|
||||
lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'),
|
||||
)
|
||||
.finally(() => lebt && setLaedt(false))
|
||||
return () => {
|
||||
lebt = false
|
||||
}
|
||||
}, [note])
|
||||
|
||||
if (laedt) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!stats) return <Leer text="Die Statistik ließ sich nicht laden." />
|
||||
|
||||
const groesster = Math.max(1, ...stats.folders.map((f) => f.bytes))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Zahl kind={<HardDrive size={16} />} was="Dateien" wert={String(stats.files)} />
|
||||
<Zahl kind={<Folder size={16} />} was="Belegt" wert={bytes(stats.bytes)} />
|
||||
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
|
||||
</div>
|
||||
|
||||
<Karte titel="Öffentliche Adresse">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
|
||||
{stats.publicUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => void kopieren(stats.publicUrl, 'url')}
|
||||
className="rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="Adresse kopieren"
|
||||
>
|
||||
{kopiert === 'url' ? (
|
||||
<Check size={14} className="text-gut" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-schrift-leise">
|
||||
Dateien liegen darunter unter <code className="font-mono">/f/</code>. Diese
|
||||
Adresse steht nirgends in der Datenbank — sie wird bei jeder Antwort aus{' '}
|
||||
<code className="font-mono">PUBLIC_URL</code> gebaut, ein Umzug ist deshalb
|
||||
eine geänderte Variable.
|
||||
</p>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Größte Ordner">
|
||||
{stats.folders.length === 0 ? (
|
||||
<Leer text="Noch nichts abgelegt." />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-schrift-leise">
|
||||
<th className="pb-2 font-medium">Ordner</th>
|
||||
<th className="pb-2 text-right font-medium">Dateien</th>
|
||||
<th className="pb-2 text-right font-medium">Belegt</th>
|
||||
<th className="w-1/3 pb-2 pl-4 font-medium">Anteil</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.folders.map((f) => (
|
||||
<tr key={f.folder} className="border-t border-rand">
|
||||
<td className="py-2 font-mono">{f.folder}</td>
|
||||
<td className="py-2 text-right text-schrift-leise">
|
||||
{f.files}
|
||||
</td>
|
||||
<td className="py-2 text-right">{bytes(f.bytes)}</td>
|
||||
<td className="py-2 pl-4">
|
||||
<div className="h-1.5 w-full rounded-full bg-grund">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-akzent"
|
||||
style={{
|
||||
width: `${(f.bytes / groesster) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Karte>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-rand bg-flaeche p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-schrift-leise">
|
||||
{kind}
|
||||
{was}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold">{wert}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { api, type NewToken, type TokenRow } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { zeit } from '../format'
|
||||
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
|
||||
export function Tokens() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [rows, setRows] = useState<TokenRow[] | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [darfLoeschen, setDarfLoeschen] = useState(false)
|
||||
const [legtAn, setLegtAn] = useState(false)
|
||||
|
||||
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
||||
const [weg, setWeg] = useState<TokenRow | null>(null)
|
||||
const [loescht, setLoescht] = useState(false)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
async function laden() {
|
||||
setLaedt(true)
|
||||
try {
|
||||
const { items } = await api.tokens()
|
||||
setRows(items)
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function anlegen(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setLegtAn(true)
|
||||
const neu = await run(
|
||||
() => api.createToken(name.trim(), prefix.trim(), darfLoeschen),
|
||||
(t) => `Token "${t.name}" angelegt.`,
|
||||
)
|
||||
setLegtAn(false)
|
||||
if (neu) {
|
||||
setFrisch(neu)
|
||||
setName('')
|
||||
setPrefix('')
|
||||
setDarfLoeschen(false)
|
||||
void laden()
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
if (!weg) return
|
||||
setLoescht(true)
|
||||
const ok = await run(() => api.deleteToken(weg.id), `Token "${weg.name}" gelöscht.`)
|
||||
setLoescht(false)
|
||||
setWeg(null)
|
||||
if (ok) void laden()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Karte titel="Neuen Token anlegen">
|
||||
<form onSubmit={anlegen} className="flex flex-col gap-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld
|
||||
label="Name"
|
||||
value={name}
|
||||
placeholder="d4rk_photostudio"
|
||||
hinweis="Nur zur Wiedererkennung in dieser Liste."
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Präfix (optional)"
|
||||
value={prefix}
|
||||
placeholder="vehicles"
|
||||
hinweis="Der Token darf dann nur darunter schreiben."
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darfLoeschen}
|
||||
onChange={(e) => setDarfLoeschen(e.target.checked)}
|
||||
className="mt-0.5 accent-akzent"
|
||||
/>
|
||||
<span>
|
||||
darf löschen
|
||||
<span className="block text-xs text-schrift-leise">
|
||||
Ohne dieses Häkchen kann der Token nur schreiben. Ein Werkzeug,
|
||||
das nur Bilder ablegt, braucht es nicht.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Knopf
|
||||
art="haupt"
|
||||
type="submit"
|
||||
wartet={legtAn}
|
||||
disabled={!name.trim()}
|
||||
kind={
|
||||
<>
|
||||
<Plus size={15} />
|
||||
Anlegen
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Vorhandene Token">
|
||||
{laedt ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
) : !rows || rows.length === 0 ? (
|
||||
<Leer
|
||||
kind={<KeyRound size={26} className="text-schrift-leise/50" />}
|
||||
text="Noch kein Token angelegt."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-schrift-leise">
|
||||
<th className="pb-2 font-medium">Name</th>
|
||||
<th className="pb-2 font-medium">Präfix</th>
|
||||
<th className="pb-2 font-medium">Rechte</th>
|
||||
<th className="pb-2 font-medium">Angelegt</th>
|
||||
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
||||
<th className="pb-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id} className="border-t border-rand">
|
||||
<td className="py-2 font-medium">{t.name}</td>
|
||||
<td className="py-2 font-mono text-schrift-leise">
|
||||
{t.prefix ? `${t.prefix}/` : '— (überall)'}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{t.can_delete ? (
|
||||
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
|
||||
schreiben + löschen
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-schrift-leise">
|
||||
nur schreiben
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-schrift-leise">
|
||||
{zeit(t.created_at)}
|
||||
</td>
|
||||
<td className="py-2 text-schrift-leise">
|
||||
{zeit(t.last_used_at)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => setWeg(t)}
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-schlecht/15 hover:text-schlecht"
|
||||
aria-label={`Token ${t.name} löschen`}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{/* Der Klartext — genau einmal.
|
||||
Deshalb ein Dialog, den man bewusst schließen muss, und kein
|
||||
Zettel, der nach vier Sekunden verschwindet. */}
|
||||
{frisch && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl">
|
||||
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
|
||||
<KeyRound size={16} className="text-akzent" />
|
||||
Token „{frisch.name}" angelegt
|
||||
</h3>
|
||||
|
||||
<div className="px-4 py-4">
|
||||
<p className="mb-3 flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-warn">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||
<span className="text-schrift">
|
||||
Dies ist das einzige Mal, dass der Token im Klartext zu sehen
|
||||
ist. Danach steht nur noch sein Hash in der Datenbank — auch
|
||||
der Dienst kann ihn nicht mehr zeigen.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-2 font-mono text-sm">
|
||||
{frisch.token}
|
||||
</code>
|
||||
<Knopf
|
||||
art="haupt"
|
||||
onClick={async () => {
|
||||
const ok = await kopieren(frisch.token, 'neu')
|
||||
if (!ok)
|
||||
note(
|
||||
'fehler',
|
||||
'Die Zwischenablage ist gesperrt — bitte von Hand markieren.',
|
||||
)
|
||||
}}
|
||||
kind={
|
||||
kopiert === 'neu' ? (
|
||||
<>
|
||||
<Check size={15} />
|
||||
kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={15} />
|
||||
Kopieren
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-schrift-leise">
|
||||
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
|
||||
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-rand px-4 py-3">
|
||||
<Knopf
|
||||
onClick={() => setFrisch(null)}
|
||||
kind="Habe ich mir notiert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Nachfrage
|
||||
offen={weg !== null}
|
||||
titel="Token löschen?"
|
||||
bestaetigung="Token löschen"
|
||||
wartet={loescht}
|
||||
onNein={() => setWeg(null)}
|
||||
onJa={() => void loeschen()}
|
||||
text={
|
||||
<>
|
||||
<p>
|
||||
„{weg?.name}" wird sofort ungültig. Was ihn benutzt — etwa der
|
||||
Fotostudio — bekommt ab dann 401 und lädt nichts mehr hoch.
|
||||
</p>
|
||||
<p className="mt-2">Hochgeladene Dateien bleiben.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand'
|
||||
import { api, ApiError, type User } from './api'
|
||||
|
||||
// -- Rückmeldungen -------------------------------------------------------------
|
||||
//
|
||||
// DIE EINE REGEL: jeder Knopf gibt Rückmeldung, auch der erfolgreiche. Deshalb
|
||||
// steht die Meldungsliste hier im Laden und nicht in irgendeiner Seite — sie
|
||||
// gehört zu jeder von ihnen.
|
||||
|
||||
export type NoteKind = 'ok' | 'fehler' | 'hinweis'
|
||||
export type Note = { id: number; kind: NoteKind; text: string }
|
||||
|
||||
let nextNoteId = 1
|
||||
|
||||
type State = {
|
||||
user: User | null
|
||||
/** Solange true, weiß noch niemand, ob eine Sitzung besteht — in dieser
|
||||
* Zeit darf weder das Anmeldeformular noch das Dashboard aufblitzen. */
|
||||
checking: boolean
|
||||
notes: Note[]
|
||||
|
||||
checkSession: () => Promise<void>
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
|
||||
note: (kind: NoteKind, text: string) => void
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
export const useStore = create<State>((set, get) => ({
|
||||
user: null,
|
||||
checking: true,
|
||||
notes: [],
|
||||
|
||||
async checkSession() {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
set({ user, checking: false })
|
||||
} catch (err) {
|
||||
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
|
||||
// angemeldet". Alles andere ist einer und wird gesagt.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
set({ user: null, checking: false })
|
||||
return
|
||||
}
|
||||
set({ user: null, checking: false })
|
||||
get().note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
}
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const { user } = await api.login(username, password)
|
||||
set({ user })
|
||||
get().note('ok', `Angemeldet als ${user.username}.`)
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await api.logout()
|
||||
set({ user: null })
|
||||
// Die Erfolgsmeldung kommt hier NICHT: der Aufrufer geht über `run`,
|
||||
// und der meldet. Beides hieße zwei Zettel für einen Klick.
|
||||
},
|
||||
|
||||
note(kind, text) {
|
||||
const id = nextNoteId++
|
||||
set((s) => ({ notes: [...s.notes, { id, kind, text }] }))
|
||||
// Fehler bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von
|
||||
// allein verschwindet, ist ein Fehler, den niemand gelesen hat.
|
||||
if (kind !== 'fehler') {
|
||||
setTimeout(() => get().dismiss(id), 4000)
|
||||
}
|
||||
},
|
||||
|
||||
dismiss(id) {
|
||||
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }))
|
||||
},
|
||||
}))
|
||||
|
||||
/** Eine Aktion ausführen und IMMER etwas dazu sagen.
|
||||
*
|
||||
* Der Erfolgstext wird mitgegeben, der Fehlertext kommt vom Dienst. Wer
|
||||
* darüber geht, kann keinen stillen Knopf bauen — und genau darum geht es. */
|
||||
export async function run<T>(
|
||||
action: () => Promise<T>,
|
||||
erfolg: string | ((result: T) => string),
|
||||
): Promise<T | null> {
|
||||
const { note } = useStore.getState()
|
||||
try {
|
||||
const result = await action()
|
||||
note('ok', typeof erfolg === 'function' ? erfolg(result) : erfolg)
|
||||
return result
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
// Eine abgelaufene Sitzung ist kein gewöhnlicher Fehler: sie führt
|
||||
// zurück aufs Anmeldeformular, statt jeden weiteren Knopf mit
|
||||
// "nicht angemeldet" zu quittieren.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
useStore.setState({ user: null })
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
// EIN tsconfig, kein Composite-Projekt. Das spart die tsbuildinfo-Dateien
|
||||
// und die Frage, warum vite.config.js plötzlich neben vite.config.ts liegt.
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwind from '@tailwindcss/vite'
|
||||
|
||||
// @vitejs/plugin-react ist auf ^4 festgenagelt, und das ist kein Zufall: die 6
|
||||
// verlangt Vite ^8. Vite 5 bleibt (so steht es in der ROADMAP), und die 4 ist
|
||||
// die letzte Fassung, die dazu passt — nachgesehen in den peerDependencies,
|
||||
// nicht geraten.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwind()],
|
||||
|
||||
build: {
|
||||
// Der Dienst liefert die gebaute Oberflaeche aus ../web aus; der Pfad
|
||||
// steht in index.ts als `new URL('../web/', import.meta.url)`,
|
||||
// ausgehend von dist/. Wer das hier aendert, aendert ihn dort mit.
|
||||
outDir: '../web',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
server: {
|
||||
// Im Entwicklungsbetrieb laeuft die Oberflaeche unter
|
||||
// http://localhost:5173 und der Dienst im Container auf 9101. Ohne
|
||||
// diesen Umweg gaebe es zwei Ursprunge und das Sitzungs-Cookie kaeme
|
||||
// nie an.
|
||||
//
|
||||
// Dass das ueberhaupt geht, liegt an einer Ausnahme: der Container
|
||||
// setzt das Cookie mit `Secure`, und ein Browser nimmt so eines ueber
|
||||
// http NUR auf localhost an. Hier hilft die Ausnahme — beim Pruefen
|
||||
// einer LAN-Adresse fuehrt genau sie in die Irre (siehe ROADMAP).
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:9101' },
|
||||
'/f': { target: 'http://127.0.0.1:9101' },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user