feat: Die Abo-Auswahl zeigt das Markenlogo – und steht alphabetisch

Das native Dropdown fiel neben der Kategorie-Auswahl auseinander: anderer
Stil, kein Suchfeld, kein Symbol, und die Vertraege standen in
Anlagereihenfolge. Bei zwanzig Abos sucht man einen bestimmten, und die
Reihenfolge der Eingabe kennt niemand.

Jetzt dieselbe Auswahl wie bei der Kategorie. CategorySelect bekommt dafuer
zwei kleine Zugaenge statt eines zweiten Dropdowns: ein eigenes Symbol je
Eintrag (renderMarke) und eine Gruppe ohne Namen, die ohne Kopfzeile
auskommt – die Vertraege haben keine Gruppen. Alles andere, von der
Suche bis zum Schliessen beim Scrollen, bleibt eine Mechanik.

Das Logo schlaegt dabei das Symbol: Netflix erkennt man am roten N, nicht
am Wort. Die Fallunterscheidung dafuer stand schon in der Abo-Liste und
ist jetzt eine Komponente, die sich beide teilen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 10:25:17 +02:00
co-authored by Claude Opus 5
parent 0ea2ab1322
commit 6fc2a924d2
7 changed files with 70 additions and 24 deletions
+14
View File
@@ -0,0 +1,14 @@
import { api } from "../api";
import IconChip from "./IconChip";
// Das Logo schlaegt das Symbol: Netflix erkennt man am roten N, nicht am Wort
// – und erst recht nicht an einem Zahnrad. Ohne Logo bleibt das Symbol.
export default function AboMarke({ abo, size = 30 }) {
if (!abo) return null;
return abo.has_logo ? (
<img className="abo-logo" alt="" style={{ width: size, height: size }}
src={api.subscriptionLogoUrl(abo.id, abo.logo_version)} />
) : (
<IconChip name={abo.icon} color={abo.color} size={size} />
);
}
+11 -3
View File
@@ -17,6 +17,10 @@ export default function CategorySelect({
placeholder = "—",
minWidth = 0,
disabled = false,
// Eigenes Symbol je Eintrag – fuer Abos, die statt eines Icons ein
// Markenlogo tragen. Ohne Angabe bleibt es beim Icon wie bisher.
renderMarke = null,
searchPlaceholder = null,
}) {
const { t } = useTranslation();
const selectedId = value === "" || value == null ? null : Number(value);
@@ -108,6 +112,8 @@ export default function CategorySelect({
}
const ql = q.trim().toLowerCase();
const marke = (c, size) =>
(renderMarke ? renderMarke(c, size) : <IconChip name={c.icon} color={c.color} size={size} />);
return (
<div className="catsel" style={{ minWidth }}>
@@ -120,7 +126,7 @@ export default function CategorySelect({
>
{sel ? (
<span className="catsel-value">
<IconChip name={sel.icon} color={sel.color} size={22} />
{marke(sel, 22)}
<span className="ellipsis">{sel.name}</span>
</span>
) : (
@@ -143,7 +149,7 @@ export default function CategorySelect({
ref={searchRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("categories.searchPlaceholder")}
placeholder={searchPlaceholder || t("categories.searchPlaceholder")}
/>
</div>
<div className="catsel-list">
@@ -165,6 +171,7 @@ export default function CategorySelect({
const isCollapsed = !ql && collapsed.has(g.id);
return (
<div className="catsel-group" key={g.id}>
{g.name && (
<button
type="button"
className="catsel-group-head"
@@ -178,6 +185,7 @@ export default function CategorySelect({
<IconChip name={g.icon} color={g.color} size={18} />
<span className="ellipsis">{g.name}</span>
</button>
)}
{!isCollapsed &&
cats.map((c) => (
<button
@@ -186,7 +194,7 @@ export default function CategorySelect({
className={`catsel-opt${selectedId === c.id ? " selected" : ""}`}
onClick={() => choose(c.id)}
>
<IconChip name={c.icon} color={c.color} size={20} />
{marke(c, 20)}
<span className="ellipsis">{c.name}</span>
{selectedId === c.id && <Icon name="check" size={15} className="catsel-check" />}
</button>
+20 -10
View File
@@ -30,18 +30,28 @@ export default function DataTable({
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
const [order, setOrder] = useState(() => {
const p = loadPersisted(id);
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
return [...saved, ...rest];
});
// Eine neue Spalte landet dort, wo sie definiert ist – nicht am Ende.
//
// Angehaengt stand sie hinter der Aktionsspalte, also ganz rechts ausserhalb
// des Sichtbereichs: Im Menue angehakt, in der Tabelle unauffindbar. Wer die
// Reihenfolge selbst umgestellt hat, behaelt sie; eingefuegt wird nur die
// neue Spalte, hinter ihrem linken Nachbarn aus der Definition.
const einsortieren = (vorhanden) => {
const alle = columns.map((c) => c.key);
const out = vorhanden.filter((k) => colByKey[k]);
for (const k of alle) {
if (out.includes(k)) continue;
const davor = alle.slice(0, alle.indexOf(k)).reverse().find((x) => out.includes(x));
out.splice(davor ? out.indexOf(davor) + 1 : 0, 0, k);
}
return out;
};
const [order, setOrder] = useState(
() => einsortieren(loadPersisted(id).order || []));
const schluessel = columns.map((c) => c.key).join("|");
useEffect(() => {
setOrder((alt) => {
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
return fehlend.length ? [...alt, ...fehlend] : alt;
});
setOrder((alt) => (columns.every((c) => alt.includes(c.key)) ? alt : einsortieren(alt)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schluessel]);