diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index a4ae8c0..61d4366 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -283,6 +283,23 @@ dashRoutes.post('/auth/password', async (c) => { // ------------------------------------------------------------------- Medien +/** Die Medienarten, wie sie im Dashboard heissen -- und was sie in SQL sind. + * + * An EINER Stelle, weil zwei Stellen zwangslaeufig auseinanderlaufen: die + * Liste zeigte dann etwas anderes als der Zaehler daneben behauptet. + * + * "andere" ist bewusst die Verneinung der uebrigen und keine eigene Liste: + * sonst faellt ein neuer Typ (z. B. application/zip) durch alle Filter und + * ist nirgends zu finden. */ +const ARTEN: Record = { + bild: "mime LIKE 'image/%'", + video: "mime LIKE 'video/%'", + ton: "mime LIKE 'audio/%'", + dokument: "(mime = 'application/pdf' OR mime LIKE 'text/%' OR mime LIKE 'application/json%')", + andere: "(mime NOT LIKE 'image/%' AND mime NOT LIKE 'video/%' AND mime NOT LIKE 'audio/%' " + + "AND mime <> 'application/pdf' AND mime NOT LIKE 'text/%' AND mime NOT LIKE 'application/json%')", +} + dashRoutes.get('/media', (c) => { const query = c.req.query('query')?.trim() ?? '' const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200) @@ -311,6 +328,11 @@ dashRoutes.get('/media', (c) => { params.push(`${maskiert(folder)}/%`) } + // Die Art kommt aus der Adresszeile und geht in SQL -- also gegen die + // feste Tabelle getauscht und niemals durchgereicht. + const art = c.req.query('art') ?? '' + if (ARTEN[art]) bedingungen.push(ARTEN[art]) + const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' // Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS @@ -357,6 +379,41 @@ dashRoutes.get('/media', (c) => { * * Die Zahlen sind KUMULATIV: "vehicles" enthaelt, was in "vehicles/showroom" * liegt. Alles andere ueberrascht in einer Baumansicht. */ +/** Wie viele Dateien es je Art gibt -- fuer die Filterknoepfe. + * + * Mit den gerade geltenden Einschraenkungen (Ordner, Suche), sonst stuende an + * einem Knopf eine Zahl, die nach dem Klick nicht stimmt. */ +dashRoutes.get('/arten', (c) => { + const query = c.req.query('query')?.trim() ?? '' + const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? '' + const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`) + + const grund: string[] = [] + const params: unknown[] = [] + if (query) { + grund.push(`path LIKE ? ESCAPE '\\'`) + params.push(`%${maskiert(query)}%`) + } + if (folder) { + grund.push(`path LIKE ? ESCAPE '\\'`) + params.push(`${maskiert(folder)}/%`) + } + + const zaehle = (zusatz?: string) => { + const alle = zusatz ? [...grund, zusatz] : grund + const where = alle.length ? `WHERE ${alle.join(' AND ')}` : '' + return ( + db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as { + n: number + } + ).n + } + + const out: Record = { alle: zaehle() } + for (const [name, bedingung] of Object.entries(ARTEN)) out[name] = zaehle(bedingung) + return c.json(out) +}) + dashRoutes.get('/folders', (c) => { const rows = db.prepare('SELECT path, size FROM media').all() as { path: string @@ -418,6 +475,12 @@ dashRoutes.get('/media/ids', (c) => { bedingungen.push(`path LIKE ? ESCAPE '\\'`) params.push(`${maskiert(folder)}/%`) } + // MUSS dieselbe Einschraenkung haben wie die Liste: sonst waehlt "alle + // waehlen" mehr aus, als der Filter zeigt -- und geloescht wird dann + // Unsichtbares. + const art = c.req.query('art') ?? '' + if (ARTEN[art]) bedingungen.push(ARTEN[art]) + const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' const DECKEL = 5000 @@ -551,7 +614,16 @@ dashRoutes.post('/upload', async (c) => { continue } - const mime = datei.type || mimeFor(path) + // Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein + // Achselzucken. Ein Formularteil ohne erkannte Art traegt + // "application/octet-stream", und das ist weniger wert als unsere + // Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und + // handbuch.pdf allesamt unter "Anderes". + const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? '' + const mime = + gemeldet && gemeldet !== 'application/octet-stream' + ? datei.type + : mimeFor(path) await writeFileAtomic(path, data) if (kannVorschau(mime)) await writeThumb(path, data) diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 97b0e59..13cb289 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -98,11 +98,11 @@ export function App() { e.preventDefault() gehZu(s.pfad) }} - className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm - transition-colors ${ + className={`relative 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' + ? 'bg-flaeche-hoch text-schrift after:absolute after:inset-x-2.5 after:-bottom-[9px] after:h-0.5 after:rounded-full after:bg-akzent' + : 'text-schrift-leise hover:bg-flaeche-hoch/40 hover:text-schrift' }`} > @@ -130,7 +130,7 @@ export function App() { -
+
diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 27b3144..923e373 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -173,6 +173,9 @@ export type Uebersicht = { discord: boolean } +export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere' +export type Arten = Record + export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' export type Richtung = 'auf' | 'ab' @@ -287,6 +290,7 @@ export const api = { media: ( query: string, folder: string, + art: Art, limit: number, offset: number, sort: Sortierung = 'geaendert', @@ -294,13 +298,19 @@ export const api = { ) => get( `/api/dash/media?query=${encodeURIComponent(query)}` + - `&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}` + - `&sort=${sort}&dir=${dir}`, + `&folder=${encodeURIComponent(folder)}&art=${art}` + + `&limit=${limit}&offset=${offset}&sort=${sort}&dir=${dir}`, ), - /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. */ - medienIds: (query: string, folder: string) => + /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. + * Nimmt DIESELBEN Einschraenkungen wie die Liste. */ + medienIds: (query: string, folder: string, art: Art) => get<{ ids: number[]; gekappt: boolean }>( - `/api/dash/media/ids?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`, + `/api/dash/media/ids?query=${encodeURIComponent(query)}` + + `&folder=${encodeURIComponent(folder)}&art=${art}`, + ), + arten: (query: string, folder: string) => + get( + `/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`, ), folders: () => get('/api/dash/folders'), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), diff --git a/server/ui/src/components/Bausteine.tsx b/server/ui/src/components/Bausteine.tsx index 32e27e0..f0a284e 100644 --- a/server/ui/src/components/Bausteine.tsx +++ b/server/ui/src/components/Bausteine.tsx @@ -33,8 +33,9 @@ export function Knopf({ {...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}`} + text-sm font-medium transition-all active:scale-[0.97] + disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 + ${arten[art]} ${className}`} > {wartet && } {kind} @@ -78,7 +79,7 @@ export function Karte({ children: ReactNode }) { return ( -
+
{(titel || rechts) && (

{titel}

diff --git a/server/ui/src/index.css b/server/ui/src/index.css index 193c4c6..8277cb6 100644 --- a/server/ui/src/index.css +++ b/server/ui/src/index.css @@ -46,3 +46,45 @@ body { ::selection { background: var(--color-akzent-tief); } + +/* Ein ruhiger Verlauf statt einer flachen Flaeche. Er kostet nichts und nimmt + dem Dunkel die Pappigkeit — auf einem grossen Monitor sieht ein einfarbiger + Hintergrund aus wie ein Ladefehler. */ +body { + background-image: radial-gradient( + 1200px 600px at 50% -10%, + color-mix(in oklab, var(--color-akzent) 7%, transparent), + transparent 70% + ); + background-attachment: fixed; +} + +/* Bildlaufleisten im Ton der Oberflaeche. Die hellen Standardleisten von + Windows reissen in einem dunklen Fenster jedes Mal ein Loch. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-rand) transparent; +} + +/* Was neu hereinkommt, blendet auf. Ohne das springen 60 Kacheln auf einmal + ins Bild und man sucht die Stelle, an der man war. */ +@keyframes auftauchen { + from { + opacity: 0; + transform: translateY(4px); + } +} + +.auftauchen { + animation: auftauchen 0.18s ease-out; +} + +/* Wer das nicht will, bekommt es nicht. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index ee69337..11a02ca 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -7,6 +7,10 @@ import { ExternalLink, FileQuestion, ArrowDownUp, + FileText, + Film, + Layers, + Music, ImageOff, Loader2, RefreshCw, @@ -14,7 +18,17 @@ import { Trash2, X, } from 'lucide-react' -import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage, type Richtung, type Sortierung } from '../api' +import { + api, + vorschauUrl, + type Art, + type Arten, + type Folders, + type MediaItem, + type MediaPage, + type Richtung, + type Sortierung, +} from '../api' import { run, useStore } from '../store' import { bytes, istBild, zeit } from '../format' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' @@ -31,6 +45,8 @@ export function Galerie() { const [ordner, setOrdner] = useState('') const [ordnerDaten, setOrdnerDaten] = useState(null) const [seite, setSeite] = useState(0) + const [art, setArt] = useState('') + const [arten, setArten] = useState(null) const [sortierung, setSortierung] = useState('geaendert') const [richtung, setRichtung] = useState('ab') const [daten, setDaten] = useState(null) @@ -67,6 +83,7 @@ export function Galerie() { const seiteDaten = await api.media( sucheAktiv, ordner, + art, PRO_SEITE, seite * PRO_SEITE, sortierung, @@ -80,7 +97,7 @@ export function Galerie() { } finally { if (meine === lauf.current) setLaedt(false) } - }, [sucheAktiv, ordner, seite, sortierung, richtung, note]) + }, [sucheAktiv, ordner, art, seite, sortierung, richtung, note]) useEffect(() => { void laden() @@ -90,6 +107,22 @@ export function Galerie() { // haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die // "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als // keine. + // Die Zahlen an den Knoepfen haengen an Suche und Ordner, aber NICHT an + // der gewaehlten Art: sonst zeigte "Video (3)" nach dem Klick "Video (3)" + // und alle anderen 0. + const artenLaden = useCallback(async () => { + try { + setArten(await api.arten(sucheAktiv, ordner)) + } catch { + // Die Zahlen sind Beiwerk. Faellt der Aufruf aus, bleiben die + // Knoepfe ohne Zahl statt die Seite mit einem Fehler zu fluten. + } + }, [sucheAktiv, ordner]) + + useEffect(() => { + void artenLaden() + }, [artenLaden]) + const ordnerLaden = useCallback(async () => { try { setOrdnerDaten(await api.folders()) @@ -105,7 +138,8 @@ export function Galerie() { const allesLaden = useCallback(() => { void laden() void ordnerLaden() - }, [laden, ordnerLaden]) + void artenLaden() + }, [laden, ordnerLaden, artenLaden]) // Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt // es nur den Hinweis und keinen Neuaufbau unter den Haenden. @@ -131,7 +165,7 @@ export function Galerie() { async function alleTrefferWaehlen() { const antwort = await run( - () => api.medienIds(sucheAktiv, ordner), + () => api.medienIds(sucheAktiv, ordner, art), (r) => r.gekappt ? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.` @@ -198,6 +232,50 @@ export function Galerie() { />
+ {/* Nach Art filtern. Die Zahlen stehen dabei — ein Knopf "Video", + hinter dem nichts liegt, kostet einen Klick, um das + herauszufinden. */} +
+ {( + [ + ['', 'Alles', Layers], + ['bild', 'Bilder', ImageOff], + ['video', 'Video', Film], + ['ton', 'Ton', Music], + ['dokument', 'Dokumente', FileText], + ['andere', 'Anderes', FileQuestion], + ] as const + ).map(([wert, text, Symbol]) => { + const n = arten?.[wert || 'alle'] + const an = art === wert + // Leere Arten bleiben stehen statt zu verschwinden: eine + // Leiste, die ihre Knöpfe wechselt, trifft man nicht. + return ( + + ) + })} +
+ {/* Kopfzeile: Suche, Auswahl, Löschen */}
@@ -349,7 +427,12 @@ export function Galerie() {
{/* Klick auf die Kachel ÖFFNET, Klick auf das Kästchen WÄHLT AUS. Vorher tat der Klick