feat: nach Medienart filtern -- und die Endung schlaegt das Achselzucken

FILTER. Eine Leiste ueber der Galerie: Alles, Bilder, Video, Ton, Dokumente,
Anderes -- mit der Anzahl AM KNOPF. Ein Knopf "Video", hinter dem nichts liegt,
kostet sonst einen Klick, um das herauszufinden; leere Arten sind deshalb
sichtbar, aber gesperrt.

Die Uebersetzung Art -> SQL steht an EINER Stelle (ARTEN), weil zwei Stellen
zwangslaeufig auseinanderlaufen: die Liste zeigte dann etwas anderes, als der
Zaehler daneben behauptet. "Anderes" ist bewusst die VERNEINUNG der uebrigen
und keine eigene Liste -- sonst faellt ein neuer Typ durch alle Filter und ist
nirgends zu finden. Nachgemessen: die Summe der fuenf Arten ergibt die
Gesamtzahl.

/media/ids nimmt denselben Filter. Ohne das waehlt "alle waehlen" mehr aus, als
zu sehen ist -- und geloescht wuerde Unsichtbares.

DER FUND BEIM MESSEN: clip.mp4, ton.mp3, handbuch.pdf und notiz.txt landeten
ALLE unter "Anderes". Ein Formularteil ohne erkannte Art traegt
"application/octet-stream", und mein Code hat das der Endungstabelle
vorgezogen. Was der Absender ueber die Art sagt, gilt weiterhin -- aber nicht
sein Achselzucken. Danach: mp4 -> video/mp4, mp3 -> audio/mpeg, pdf und txt ->
Dokumente, und .zip bleibt richtigerweise "Anderes".

POLITUR. Ein ruhiger Verlauf im Hintergrund statt einer flachen Flaeche (ein
einfarbiges Dunkel sieht auf einem grossen Monitor aus wie ein Ladefehler),
Bildlaufleisten im Ton der Oberflaeche, ein Balken unter der aktiven Seite,
Karten mit einem Hauch Tiefe, ein spuerbarer Druckpunkt an den Knoepfen, und
Kacheln, die sich beim Darueberfahren leicht heben. Der Seitenwechsel blendet
auf -- und wer prefers-reduced-motion gesetzt hat, bekommt nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:43:58 +02:00
co-authored by Claude Opus 5
parent 4e613dd40d
commit 2efc04b0b2
6 changed files with 227 additions and 19 deletions
+73 -1
View File
@@ -283,6 +283,23 @@ dashRoutes.post('/auth/password', async (c) => {
// ------------------------------------------------------------------- Medien // ------------------------------------------------------------------- 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<string, string> = {
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) => { dashRoutes.get('/media', (c) => {
const query = c.req.query('query')?.trim() ?? '' const query = c.req.query('query')?.trim() ?? ''
const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200) const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
@@ -311,6 +328,11 @@ dashRoutes.get('/media', (c) => {
params.push(`${maskiert(folder)}/%`) 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 ')}` : '' const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
// Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS // 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" * Die Zahlen sind KUMULATIV: "vehicles" enthaelt, was in "vehicles/showroom"
* liegt. Alles andere ueberrascht in einer Baumansicht. */ * 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<string, number> = { alle: zaehle() }
for (const [name, bedingung] of Object.entries(ARTEN)) out[name] = zaehle(bedingung)
return c.json(out)
})
dashRoutes.get('/folders', (c) => { dashRoutes.get('/folders', (c) => {
const rows = db.prepare('SELECT path, size FROM media').all() as { const rows = db.prepare('SELECT path, size FROM media').all() as {
path: string path: string
@@ -418,6 +475,12 @@ dashRoutes.get('/media/ids', (c) => {
bedingungen.push(`path LIKE ? ESCAPE '\\'`) bedingungen.push(`path LIKE ? ESCAPE '\\'`)
params.push(`${maskiert(folder)}/%`) 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 where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
const DECKEL = 5000 const DECKEL = 5000
@@ -551,7 +614,16 @@ dashRoutes.post('/upload', async (c) => {
continue 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) await writeFileAtomic(path, data)
if (kannVorschau(mime)) await writeThumb(path, data) if (kannVorschau(mime)) await writeThumb(path, data)
+5 -5
View File
@@ -98,11 +98,11 @@ export function App() {
e.preventDefault() e.preventDefault()
gehZu(s.pfad) gehZu(s.pfad)
}} }}
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-1.5
transition-colors ${ text-sm transition-colors ${
an an
? 'bg-flaeche-hoch 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:text-schrift' : 'text-schrift-leise hover:bg-flaeche-hoch/40 hover:text-schrift'
}`} }`}
> >
<s.kind size={15} /> <s.kind size={15} />
@@ -130,7 +130,7 @@ export function App() {
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-5"> <main key={aktiv.pfad} className="auftauchen mx-auto w-full max-w-7xl flex-1 px-4 py-5">
<Inhalt /> <Inhalt />
</main> </main>
+15 -5
View File
@@ -173,6 +173,9 @@ export type Uebersicht = {
discord: boolean discord: boolean
} }
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'
export type Richtung = 'auf' | 'ab' export type Richtung = 'auf' | 'ab'
@@ -287,6 +290,7 @@ export const api = {
media: ( media: (
query: string, query: string,
folder: string, folder: string,
art: Art,
limit: number, limit: number,
offset: number, offset: number,
sort: Sortierung = 'geaendert', sort: Sortierung = 'geaendert',
@@ -294,13 +298,19 @@ export const api = {
) => ) =>
get<MediaPage>( get<MediaPage>(
`/api/dash/media?query=${encodeURIComponent(query)}` + `/api/dash/media?query=${encodeURIComponent(query)}` +
`&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}` + `&folder=${encodeURIComponent(folder)}&art=${art}` +
`&sort=${sort}&dir=${dir}`, `&limit=${limit}&offset=${offset}&sort=${sort}&dir=${dir}`,
), ),
/** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. */ /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern.
medienIds: (query: string, folder: string) => * Nimmt DIESELBEN Einschraenkungen wie die Liste. */
medienIds: (query: string, folder: string, art: Art) =>
get<{ ids: number[]; gekappt: boolean }>( 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<Arten>(
`/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
), ),
folders: () => get<Folders>('/api/dash/folders'), folders: () => get<Folders>('/api/dash/folders'),
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
+4 -3
View File
@@ -33,8 +33,9 @@ export function Knopf({
{...rest} {...rest}
disabled={disabled || wartet} disabled={disabled || wartet}
className={`inline-flex items-center justify-center gap-2 rounded-md border px-3 py-1.5 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 text-sm font-medium transition-all active:scale-[0.97]
disabled:opacity-50 ${arten[art]} ${className}`} disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100
${arten[art]} ${className}`}
> >
{wartet && <Loader2 size={15} className="animate-spin" />} {wartet && <Loader2 size={15} className="animate-spin" />}
{kind} {kind}
@@ -78,7 +79,7 @@ export function Karte({
children: ReactNode children: ReactNode
}) { }) {
return ( return (
<section className="rounded-lg border border-rand bg-flaeche"> <section className="rounded-lg border border-rand bg-flaeche shadow-sm shadow-black/20">
{(titel || rechts) && ( {(titel || rechts) && (
<header className="flex items-center justify-between gap-3 border-b border-rand px-4 py-2.5"> <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> <h2 className="text-sm font-semibold">{titel}</h2>
+42
View File
@@ -46,3 +46,45 @@ body {
::selection { ::selection {
background: var(--color-akzent-tief); 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;
}
}
+88 -5
View File
@@ -7,6 +7,10 @@ import {
ExternalLink, ExternalLink,
FileQuestion, FileQuestion,
ArrowDownUp, ArrowDownUp,
FileText,
Film,
Layers,
Music,
ImageOff, ImageOff,
Loader2, Loader2,
RefreshCw, RefreshCw,
@@ -14,7 +18,17 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react' } 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 { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format' import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
@@ -31,6 +45,8 @@ export function Galerie() {
const [ordner, setOrdner] = useState('') const [ordner, setOrdner] = useState('')
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null) const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
const [seite, setSeite] = useState(0) const [seite, setSeite] = useState(0)
const [art, setArt] = useState<Art>('')
const [arten, setArten] = useState<Arten | null>(null)
const [sortierung, setSortierung] = useState<Sortierung>('geaendert') const [sortierung, setSortierung] = useState<Sortierung>('geaendert')
const [richtung, setRichtung] = useState<Richtung>('ab') const [richtung, setRichtung] = useState<Richtung>('ab')
const [daten, setDaten] = useState<MediaPage | null>(null) const [daten, setDaten] = useState<MediaPage | null>(null)
@@ -67,6 +83,7 @@ export function Galerie() {
const seiteDaten = await api.media( const seiteDaten = await api.media(
sucheAktiv, sucheAktiv,
ordner, ordner,
art,
PRO_SEITE, PRO_SEITE,
seite * PRO_SEITE, seite * PRO_SEITE,
sortierung, sortierung,
@@ -80,7 +97,7 @@ export function Galerie() {
} finally { } finally {
if (meine === lauf.current) setLaedt(false) if (meine === lauf.current) setLaedt(false)
} }
}, [sucheAktiv, ordner, seite, sortierung, richtung, note]) }, [sucheAktiv, ordner, art, seite, sortierung, richtung, note])
useEffect(() => { useEffect(() => {
void laden() void laden()
@@ -90,6 +107,22 @@ export function Galerie() {
// haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die // haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die
// "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als // "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als
// keine. // 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 () => { const ordnerLaden = useCallback(async () => {
try { try {
setOrdnerDaten(await api.folders()) setOrdnerDaten(await api.folders())
@@ -105,7 +138,8 @@ export function Galerie() {
const allesLaden = useCallback(() => { const allesLaden = useCallback(() => {
void laden() void laden()
void ordnerLaden() void ordnerLaden()
}, [laden, ordnerLaden]) void artenLaden()
}, [laden, ordnerLaden, artenLaden])
// Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt // Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt
// es nur den Hinweis und keinen Neuaufbau unter den Haenden. // es nur den Hinweis und keinen Neuaufbau unter den Haenden.
@@ -131,7 +165,7 @@ export function Galerie() {
async function alleTrefferWaehlen() { async function alleTrefferWaehlen() {
const antwort = await run( const antwort = await run(
() => api.medienIds(sucheAktiv, ordner), () => api.medienIds(sucheAktiv, ordner, art),
(r) => (r) =>
r.gekappt r.gekappt
? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.` ? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.`
@@ -198,6 +232,50 @@ export function Galerie() {
/> />
<div className="flex min-w-0 flex-1 flex-col gap-4"> <div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Nach Art filtern. Die Zahlen stehen dabei — ein Knopf "Video",
hinter dem nichts liegt, kostet einen Klick, um das
herauszufinden. */}
<div className="flex flex-wrap items-center gap-1.5">
{(
[
['', '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 (
<button
key={wert}
onClick={() => {
setArt(wert as Art)
setSeite(0)
setGewaehlt(new Set())
}}
disabled={n === 0 && wert !== ''}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
<Symbol size={13} />
{text}
{n !== undefined && (
<span className="tabular-nums opacity-70">{n}</span>
)}
</button>
)
})}
</div>
{/* Kopfzeile: Suche, Auswahl, Löschen */} {/* Kopfzeile: Suche, Auswahl, Löschen */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1"> <div className="relative min-w-56 flex-1">
@@ -349,7 +427,12 @@ export function Galerie() {
<figure <figure
key={item.id} key={item.id}
className={`group relative overflow-hidden rounded-lg border bg-flaeche className={`group relative overflow-hidden rounded-lg border bg-flaeche
transition-colors ${an ? 'border-akzent' : 'border-rand'}`} transition-all hover:-translate-y-0.5 hover:shadow-lg
hover:shadow-black/30 ${
an
? 'border-akzent ring-1 ring-akzent/40'
: 'border-rand hover:border-schrift-leise/40'
}`}
> >
{/* Klick auf die Kachel ÖFFNET, Klick auf das {/* Klick auf die Kachel ÖFFNET, Klick auf das
Kästchen WÄHLT AUS. Vorher tat der Klick Kästchen WÄHLT AUS. Vorher tat der Klick