Author SHA1 Message Date
unknownandClaude Opus 5.5 295da414bb docs: neue navigation in anleitung, schnellstart und screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:13 +01:00
unknownandClaude Opus 5.5 d5ffcbae60 feat(navigation): plus in der mitte, mehr-seite und sammler-umschalter
Unten Start, Katalog, Sammlung, Wuensche und ein Plus mit allen Wegen zu
einem neuen Set. Konto, Design und Stammdaten auf der Seite "Mehr" statt
im Konto-Menue und in sieben Reitern. Bei mehreren Sammlern waehlt "Fuer
wen?" in der Kopfzeile das Kind fuer die ganze App, gemerkt pro Geraet.
Behebt nebenbei, dass Mitglieder die Stammdaten nicht erreichten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:13 +01:00
52 changed files with 838 additions and 344 deletions
+18
View File
@@ -7,6 +7,24 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Geändert
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
Lego-Nummer, von Hand).
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
Export/Import als Kacheln in Gruppen, oben rechts hinter den Initialen.
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
### Behoben
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
„Verwaltung" stand nur im Menü des Administrators.
### Hinzugefügt ### Hinzugefügt
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo, - **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
+2
View File
@@ -63,6 +63,8 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan | | `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | | `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
| `src/lib/collector-choice.ts` | Aktiver Sammler: `?sammler=` vor Cookie aus dem Umschalter „Fuer wen?" |
| `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) | | `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) |
| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen | | `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen |
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` | | `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
+5 -5
View File
@@ -144,16 +144,16 @@ tut.
## 7. Passkey hinterlegen ## 7. Passkey hinterlegen
Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (das Kästchen mit deinen Initialen oben rechts). Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop. eigener Passkey – Handy, Tablet, Laptop.
## 8. Loslegen ## 8. Loslegen
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. 1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … 2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … 3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
4. **Katalog → Set anlegen**: Setnummer, Name, Bild. 4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. 5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, 6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
**„Zur Wunschliste hinzufügen"**. **„Zur Wunschliste hinzufügen"**.
+33 -16
View File
@@ -63,7 +63,21 @@ Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
| **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set | | **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
erreichbar. Der aktive Bereich sitzt auf einer kleinen Platte. erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
![Das Plus-Menü](bilder/26-plus-menue.png)
Das **+** bündelt alle Wege, wie ein Set in den Katalog kommt: Strichcode
scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
![Die Mehr-Seite](bilder/27-mehr.png)
Alles, was man nicht täglich braucht, liegt hinter dem Kästchen mit deinen
Initialen oben rechts – der Seite **Mehr**: Konto und Passkeys, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
nicht erst.
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün. Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
@@ -74,8 +88,9 @@ Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
![Hersteller verwalten](bilder/03-verwaltung-hersteller.png) ![Hersteller verwalten](bilder/03-verwaltung-hersteller.png)
Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**.
über das Konto-Menü oben rechts → **Verwaltung**. Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus
einem Link legen fehlende Hersteller selbst an.
Drei Arten von Stammdaten: Drei Arten von Stammdaten:
@@ -117,7 +132,7 @@ Die Kacheln zeigen auf einen Blick:
- **„Auf Wunschliste"** – steht auf mindestens einer Liste - **„Auf Wunschliste"** – steht auf mindestens einer Liste
- Setnummer, Hersteller, Thema, Jahr und UVP - Setnummer, Hersteller, Thema, Jahr und UVP
Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
hinterlegst eine Bild-URL. hinterlegst eine Bild-URL.
@@ -286,7 +301,7 @@ zweites Mal.
![Konto und Passkeys](bilder/16-konto-passkeys.png) ![Konto und Passkeys](bilder/16-konto-passkeys.png)
Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck, Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
der Browser den Passkey sogar von selbst vor. der Browser den Passkey sogar von selbst vor.
@@ -310,7 +325,7 @@ Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
### Aus einem Link übernehmen – für alle Hersteller ### Aus einem Link übernehmen – für alle Hersteller
Auf **Katalog → Set anlegen** steht das Feld „Aus einem Link übernehmen". Über **+ → Aus Link** landest du im Feld „Aus einem Link übernehmen".
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
des Herstellers oder eine Set-Datenbank wie des Herstellers oder eine Set-Datenbank wie
[Brickfact](https://brickfact.com) (Lego) und [Brickfact](https://brickfact.com) (Lego) und
@@ -340,7 +355,7 @@ Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
![Lego-Import](bilder/17-lego-import.png) ![Lego-Import](bilder/17-lego-import.png)
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer Über **+ → Lego-Nummer** landest du im Feld für den Import. Setnummer
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
@@ -393,7 +408,7 @@ der Sammlung stimmt damit, ohne dass du daran denken musst.
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
gibt es deshalb **Verwaltung → Einladungen**: gibt es deshalb **Mehr → Einladungen**:
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle 1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
und Gültigkeit. und Gültigkeit.
@@ -411,7 +426,7 @@ zusätzlich einladen und Daten exportieren.
![Daten](bilder/20-daten.png) ![Daten](bilder/20-daten.png)
Unter **Verwaltung → Daten** gibt es drei Downloads: Unter **Mehr → Export & Import** gibt es drei Downloads:
| | Wofür | | | Wofür |
|---|---| |---|---|
@@ -471,14 +486,16 @@ Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du > vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
> es auch. > es auch.
Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine Anlegen unter **Mehr → Sammler**. Jeder bekommt einen Namen und eine
Farbe zur Unterscheidung. Farbe zur Unterscheidung.
![Nach Sammler filtern](bilder/22-sammler-filter.png) ![Nach Sammler filtern](bilder/22-sammler-filter.png)
Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen" Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle"
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der
der Frage, was **diesem** Kind noch fehlt. Startseite, im Katalog und in der Sammlung, inklusive der Frage, was
**diesem** Kind noch fehlt. Die Wahl merkt sich jedes Gerät: Das Tablet im
Kinderzimmer öffnet direkt bei Max.
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
@@ -486,7 +503,7 @@ als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update > Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört. > entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
> Umbenennen unter **Verwaltung → Sammler**, fertig. > Umbenennen unter **Mehr → Sammler**, fertig.
--- ---
@@ -534,7 +551,7 @@ SMTP_FROM=Bricktrack <bricktrack@example.de>
REMINDER_DAYS_BEFORE=14 REMINDER_DAYS_BEFORE=14
``` ```
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine Unter **Mehr → Erinnerungen** kannst du die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist. viel dort noch offen ist.
@@ -583,7 +600,7 @@ Haken dran, habt ihr es schon.
![Startseite im Design Nachtblau](bilder/25-design-nachtblau.png) ![Startseite im Design Nachtblau](bilder/25-design-nachtblau.png)
Unter **Konto → Design** gibt es sechs Looks zur Auswahl: Unter **Mehr → Design** gibt es sechs Looks zur Auswahl:
| Design | | | Design | |
|---|---| |---|---|
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+16 -2
View File
@@ -52,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/'); await visit(page, '/');
await shoot(page, '02-start'); await shoot(page, '02-start');
// The plus in the middle of the navigation, opened.
await page.getByRole('button', { name: 'Neues Set' }).click();
await expect(page.getByText('Was kommt dazu?')).toBeVisible();
await page.waitForTimeout(400); // let the sheet finish sliding in
await page.screenshot({ path: shotPath('26-plus-menue') });
await page.keyboard.press('Escape');
await visit(page, '/mehr');
await expect(page.getByText('Katalog pflegen')).toBeVisible();
await shoot(page, '27-mehr');
// --- 3. master data ------------------------------------------------------- // --- 3. master data -------------------------------------------------------
await visit(page, '/verwaltung/hersteller'); await visit(page, '/verwaltung/hersteller');
await expect(page.getByText('Lego', { exact: true })).toBeVisible(); await expect(page.getByText('Lego', { exact: true })).toBeVisible();
@@ -186,10 +197,13 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
// The switcher only exists because the seed created two collectors. // The switcher only exists because the seed created two collectors.
await visit(page, '/katalog?besitz=fehlt'); await visit(page, '/katalog?besitz=fehlt');
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible(); await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
await page.getByRole('button', { name: /Max/ }).click(); await page.getByRole('radio', { name: /Max/ }).click();
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible(); await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
await shoot(page, '22-sammler-filter'); await shoot(page, '22-sammler-filter');
// Back to everybody, so the following pictures show the whole family.
await page.getByRole('radio', { name: /Alle/ }).click();
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
// --- 17. prices ----------------------------------------------------------- // --- 17. prices -----------------------------------------------------------
await visit(page, '/katalog?q=60398'); await visit(page, '/katalog?q=60398');
+38
View File
@@ -0,0 +1,38 @@
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { env } from '@/env';
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
import { resolveCollectorId } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
const schema = z.object({ id: z.string().max(64) });
/** Remembers the collector chosen in the header; an empty id means "alle". */
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = schema.safeParse(input);
if (!parsed.success) return actionError('Ungültige Auswahl.');
const store = await cookies();
if (parsed.data.id === '') {
store.delete(COLLECTOR_COOKIE);
} else {
const id = resolveCollectorId(parsed.data.id);
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
store.set(COLLECTOR_COOKIE, id, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
}
revalidatePath('/', 'layout');
return actionOk();
}
+3 -2
View File
@@ -23,7 +23,8 @@ import {
listPricePoints, listPricePoints,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection'; import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listLocations } from '@/lib/queries/master-data'; import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -47,7 +48,7 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
// Keeps the "vorhanden / fehlt" badge in step with the collector the user // Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog. // was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler; const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = resolveCollectorId(rawCollector); const collectorId = await activeCollectorId(rawCollector);
const collectors = listCollectors(); const collectors = listCollectors();
const set = getSetById(id, collectorId); const set = getSetById(id, collectorId);
+9 -2
View File
@@ -15,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
await requireUser(); await requireUser();
const params = await searchParams; const params = await searchParams;
const ean = typeof params.ean === 'string' ? params.ean : undefined; const ean = typeof params.ean === 'string' ? params.ean : undefined;
// Which way the plus menu chose: the matching field gets the cursor.
const way = typeof params.weg === 'string' ? params.weg : undefined;
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({ const themes = listThemes().map((row) => ({
@@ -38,9 +40,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
</div> </div>
{/* The Lego import creates its manufacturer itself, so it works even {/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */} on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} /> <RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
<NewSetEntry manufacturers={manufacturers} themes={themes} defaultEan={ean}> <NewSetEntry
manufacturers={manufacturers}
themes={themes}
defaultEan={ean}
focus={way === 'link' || way === 'hand' ? way : undefined}
>
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht. was auch immer im Kinderzimmer steht.
+3 -8
View File
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner'; import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { CatalogGrid } from '@/components/katalog/catalog-grid';
@@ -10,7 +9,8 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format'; import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership'; import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -30,7 +30,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const ownership = parseOwnershipFilter(single(params.besitz)); const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors(); const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler)); const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null; const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr); const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1; const page = Number(single(params.seite) ?? '1') || 1;
@@ -80,11 +80,6 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</div> </div>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} /> <CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+17 -3
View File
@@ -1,5 +1,8 @@
import { AppHeader } from '@/components/layout/app-header'; import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav'; import { BottomNav } from '@/components/layout/bottom-nav';
import { storedCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { ensureReminderScheduler } from '@/lib/reminder-job'; import { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -9,13 +12,24 @@ export default async function AppLayout({ children }: { children: React.ReactNod
ensureReminderScheduler(); ensureReminderScheduler();
const user = await requireUser(); const user = await requireUser();
const collectors = listCollectors().map((row) => ({
id: row.id,
name: row.name,
color: row.color,
}));
const legoImport = isRebrickableEnabled();
return ( return (
<div className="flex min-h-dvh flex-col"> <div className="flex min-h-dvh flex-col">
<AppHeader user={user} /> <AppHeader
user={user}
collectors={collectors}
storedCollectorId={(await storedCollectorId()) ?? null}
legoImport={legoImport}
/>
{/* Bottom padding keeps the mobile nav from covering the last row. */} {/* Bottom padding keeps the mobile nav from covering the last row. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
<BottomNav /> <BottomNav legoImport={legoImport} />
</div> </div>
); );
} }
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
import { SignOutButton } from '@/components/layout/sign-out-button';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Mehr' };
const GROUP_TONES: Record<string, string> = {
ich: 'bg-tone-1',
familie: 'bg-tone-2',
katalog: 'bg-tone-3',
daten: 'bg-tone-4',
};
export default async function MehrPage() {
const user = await requireUser();
const items = moreItemsFor(user.role);
return (
<div className="space-y-7">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
<p className="text-sm text-muted-foreground">
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
</p>
</div>
</div>
{MORE_GROUPS.map((group) => {
const entries = items.filter((item) => item.group === group.id);
if (entries.length === 0) return null;
return (
<section key={group.id} className="space-y-4">
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
{group.label}
</h2>
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
{entries.map((item) => (
<li key={item.href} className="relative">
<Studs studClassName={GROUP_TONES[group.id]} />
<Link
href={item.href}
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
GROUP_TONES[group.id],
)}
>
<item.icon className="size-6" aria-hidden />
<span className="font-bold">{item.label}</span>
<span className="text-xs leading-snug opacity-80">{item.description}</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
<SignOutButton />
</div>
);
}
+19 -7
View File
@@ -4,6 +4,7 @@ import { cn } from 'cn';
import { Studs } from '@/components/brick/studs'; import { Studs } from '@/components/brick/studs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { brickRow } from '@/lib/brick-row'; import { brickRow } from '@/lib/brick-row';
import { activeCollectorId } from '@/lib/collector-choice';
import { collectorDotClass } from '@/lib/collector-colors'; import { collectorDotClass } from '@/lib/collector-colors';
import { formatDate, formatNumber, pluralize } from '@/lib/format'; import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
@@ -19,20 +20,29 @@ function daysLabel(days: number): string {
return `in ${days} Tagen`; return `in ${days} Tagen`;
} }
export default async function DashboardPage() { type SearchParams = Promise<Record<string, string | string[] | undefined>>;
const user = await requireUser();
const stats = getCollectionStats(); export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const missing = countMissingSets(); const user = await requireUser();
const params = await searchParams;
const rawCollector = Array.isArray(params.sammler) ? params.sammler[0] : params.sammler;
// With a child chosen in the header, the start page is about that child.
const collectorId = await activeCollectorId(rawCollector);
const stats = getCollectionStats(collectorId);
const missing = countMissingSets(collectorId);
const totalSets = stats.setCount + missing; const totalSets = stats.setCount + missing;
const collectors = listCollectors(); const collectors = listCollectors();
const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null;
const perCollector = countItemsPerCollector(); const perCollector = countItemsPerCollector();
const showCollectors = collectors.length > 1; const showCollectors = collectors.length > 1 && !activeCollector;
const bricks = brickRow( const bricks = brickRow(
stats.setCount, stats.setCount,
totalSets, totalSets,
showCollectors activeCollector
? [{ key: activeCollector.id, count: 1 }]
: showCollectors
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 })) ? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
: [], : [],
); );
@@ -53,7 +63,9 @@ export default async function DashboardPage() {
<Studs count={3} /> <Studs count={3} />
<div className="brick rounded-xl bg-card p-4 text-card-foreground"> <div className="brick rounded-xl bg-card p-4 text-card-foreground">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold">Unsere Sammlung</h2> <h2 className="text-lg font-semibold">
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
</h2>
<p className="text-sm font-semibold text-muted-foreground"> <p className="text-sm font-semibold text-muted-foreground">
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')} {formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
</p> </p>
+3 -8
View File
@@ -3,7 +3,6 @@ import Link from 'next/link';
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react'; import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder'; import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs'; import { Studs } from '@/components/brick/studs';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters'; import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@@ -19,7 +18,8 @@ import {
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
} from '@/lib/queries/collection'; } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data'; import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types'; import type { CollectionStatus, Completeness } from '@/db/types';
@@ -38,7 +38,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
const params = await searchParams; const params = await searchParams;
const collectors = listCollectors(); const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler)); const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null; const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = { const filters: Filters = {
@@ -78,11 +78,6 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
</p> </p>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => ( {tiles.map((tile) => (
<Card key={tile.label}> <Card key={tile.label}>
+3 -9
View File
@@ -1,21 +1,15 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav'; import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' }; export const metadata: Metadata = { title: 'Verwaltung' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser(); await requireUser();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <VerwaltungHeader />
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
<p className="text-sm text-muted-foreground">
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p>
</div>
<VerwaltungNav isAdmin={user.role === 'admin'} />
{children} {children}
</div> </div>
); );
+26 -14
View File
@@ -25,13 +25,18 @@ type Controls = { stop: () => void };
* loaded only when the scanner is opened - nothing leaves the device, and the * loaded only when the scanner is opened - nothing leaves the device, and the
* catalog pages stay light for everyone who never scans. * catalog pages stay light for everyone who never scans.
*/ */
export function BarcodeScannerButton() { export function BarcodeScannerDialog({
open,
onOpenChange: setOpen,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter(); const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const controlsRef = useRef<Controls | null>(null); const controlsRef = useRef<Controls | null>(null);
const handledRef = useRef(false); const handledRef = useRef(false);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -68,7 +73,7 @@ export function BarcodeScannerButton() {
router.refresh(); router.refresh();
}); });
}, },
[router, stop], [router, setOpen, stop],
); );
useEffect(() => { useEffect(() => {
@@ -135,17 +140,6 @@ export function BarcodeScannerButton() {
}, [open, onDetected, stop]); }, [open, onDetected, stop]);
return ( return (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<Dialog <Dialog
open={open} open={open}
onOpenChange={(next) => { onOpenChange={(next) => {
@@ -194,6 +188,24 @@ export function BarcodeScannerButton() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
);
}
export function BarcodeScannerButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
</> </>
); );
} }
+12 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import { Link2, Loader2 } from 'lucide-react'; import { Link2, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions'; import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
@@ -35,11 +35,14 @@ export function NewSetEntry({
manufacturers, manufacturers,
themes, themes,
defaultEan, defaultEan,
focus,
children, children,
}: { }: {
manufacturers: Array<{ id: string; name: string }>; manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>; themes: Array<{ id: string; name: string; manufacturerId: string }>;
defaultEan?: string; defaultEan?: string;
/** Set by the plus menu: start in the link field or in the form. */
focus?: 'link' | 'hand';
/** Shown instead of the form while there is no manufacturer yet. */ /** Shown instead of the form while there is no manufacturer yet. */
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
@@ -50,6 +53,13 @@ export function NewSetEntry({
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null); const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
useEffect(() => {
if (focus !== 'hand') return;
const field = document.getElementById('setNumber');
field?.scrollIntoView({ block: 'center' });
field?.focus({ preventScroll: true });
}, [focus]);
function submit(event: React.FormEvent<HTMLFormElement>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setExisting(null); setExisting(null);
@@ -114,6 +124,7 @@ export function NewSetEntry({
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
placeholder="https://…" placeholder="https://…"
maxLength={2000} maxLength={2000}
autoFocus={focus === 'link'}
/> />
</Field> </Field>
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}> <Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
* One-field Lego import: type the number from the box, get a finished catalog * One-field Lego import: type the number from the box, get a finished catalog
* entry. Hidden entirely when no API key is configured. * entry. Hidden entirely when no API key is configured.
*/ */
export function RebrickableImport({ enabled }: { enabled: boolean }) { export function RebrickableImport({ enabled, autoFocus }: { enabled: boolean; autoFocus?: boolean }) {
const router = useRouter(); const router = useRouter();
const [setNumber, setSetNumber] = useState(''); const [setNumber, setSetNumber] = useState('');
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
onChange={(event) => setSetNumber(event.target.value)} onChange={(event) => setSetNumber(event.target.value)}
placeholder="60398" placeholder="60398"
inputMode="numeric" inputMode="numeric"
autoFocus={autoFocus}
maxLength={40} maxLength={40}
className="font-mono" className="font-mono"
/> />
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Hash, Link2, PencilLine, Plus, ScanLine, type LucideIcon } from 'lucide-react';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { BarcodeScannerDialog } from '@/components/katalog/barcode-scanner';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
type Action = {
id: string;
label: string;
hint: string;
icon: LucideIcon;
tone: string;
href?: string;
};
/**
* The plus in the middle of the navigation: every way a set gets into the
* catalog, in one place. Opens as a sheet from the bottom.
*/
export function AddMenu({
legoImport,
variant = 'nav',
}: {
/** Rebrickable is configured. */
legoImport: boolean;
variant?: 'nav' | 'button';
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [scanning, setScanning] = useState(false);
const actions: Action[] = [
{ id: 'scan', label: 'Scannen', hint: 'Strichcode der Packung', icon: ScanLine, tone: 'bg-tone-1' },
{
id: 'link',
label: 'Aus Link',
hint: 'Amazon, idealo, Shop',
icon: Link2,
tone: 'bg-tone-2',
href: '/katalog/neu?weg=link',
},
...(legoImport
? [
{
id: 'lego',
label: 'Lego-Nummer',
hint: 'über Rebrickable',
icon: Hash,
tone: 'bg-tone-3',
href: '/katalog/neu?weg=lego',
},
]
: []),
{
id: 'hand',
label: 'Von Hand',
hint: 'alles selbst eintragen',
icon: PencilLine,
tone: 'bg-card',
href: '/katalog/neu?weg=hand',
},
];
function run(action: Action) {
setOpen(false);
if (action.id === 'scan') {
setScanning(true);
return;
}
if (action.href) router.push(action.href);
}
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{variant === 'nav' ? (
<button
type="button"
aria-label="Neues Set"
className="relative -mt-7 flex h-14 w-16 items-center justify-center rounded-[calc(var(--radius)*0.9)] border-[length:var(--edge-w)] border-edge bg-primary text-primary-foreground shadow-(--lift) outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-0.5"
>
<Studs count={2} className="left-2.5 gap-3 [&>.stud]:w-3.5 [&>.stud]:bg-primary" />
<Plus className={cn('size-7 transition-transform', open && 'rotate-45')} strokeWidth={3} />
</button>
) : (
<Button size="lg">
<Plus />
Neu
</Button>
)}
</SheetTrigger>
<SheetContent
side="bottom"
className="rounded-t-[calc(var(--radius)*1.6)] border-t-[length:max(1px,var(--edge-w))] border-edge bg-background pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
>
<SheetHeader>
<SheetTitle className="text-xl">Was kommt dazu?</SheetTitle>
<SheetDescription>Ein Set in den Katalog bringen – auf dem schnellsten Weg.</SheetDescription>
</SheetHeader>
<div className="mx-auto grid w-full max-w-lg grid-cols-2 gap-x-3 gap-y-5 px-4 pt-2">
{actions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => run(action)}
className="relative text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Studs studClassName={action.tone} />
<span
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-foreground',
action.tone,
action.tone !== 'bg-card' && 'text-tone-ink',
)}
>
<action.icon className="size-6" aria-hidden />
<span className="text-base font-bold">{action.label}</span>
<span className="text-xs font-semibold opacity-80">{action.hint}</span>
</span>
</button>
))}
</div>
</SheetContent>
</Sheet>
<BarcodeScannerDialog open={scanning} onOpenChange={setScanning} />
</>
);
}
+38 -4
View File
@@ -1,10 +1,27 @@
import { Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { BrickLogo } from '@/components/brick/logo'; import { BrickLogo } from '@/components/brick/logo';
import { DesktopNav } from './bottom-nav'; import { DesktopNav } from './bottom-nav';
import { UserMenu } from './user-menu'; import { CollectorSwitch, type CollectorOption } from './collector-switch';
import type { SessionUser } from '@/lib/session'; import type { SessionUser } from '@/lib/session';
export function AppHeader({ user }: { user: SessionUser }) { function initials(name: string): string {
const parts = name.trim().split(/\s+/);
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
return letters.toUpperCase() || '?';
}
export function AppHeader({
user,
collectors,
storedCollectorId,
legoImport,
}: {
user: SessionUser;
collectors: CollectorOption[];
storedCollectorId: string | null;
legoImport: boolean;
}) {
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4"> <div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
@@ -16,9 +33,26 @@ export function AppHeader({ user }: { user: SessionUser }) {
<span>Bricktrack</span> <span>Bricktrack</span>
</Link> </Link>
<div className="flex-1" /> <div className="flex-1" />
<DesktopNav /> <DesktopNav legoImport={legoImport} />
<UserMenu user={user} /> {/* Account, design and master data: everything that is not daily use. */}
<Link
href="/mehr"
aria-label="Mehr: Konto, Design und Verwaltung"
title="Mehr"
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{initials(user.name)}
</Link>
</div> </div>
{collectors.length > 1 ? (
<div className="mx-auto max-w-5xl px-4 pb-2">
{/* useSearchParams needs a boundary; the switch reads ?sammler=. */}
<Suspense fallback={<div className="h-11" />}>
<CollectorSwitch collectors={collectors} storedId={storedCollectorId} />
</Suspense>
</div>
) : null}
</header> </header>
); );
} }
+56 -35
View File
@@ -2,17 +2,52 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Boxes, Gift, LayoutGrid, User } from 'lucide-react'; import { Boxes, Gift, House, LayoutGrid } from 'lucide-react';
import { cn } from 'cn'; import { cn } from 'cn';
import { AddMenu } from './add-menu';
/**
* Four places and the plus in the middle. Account, design and master data
* sit behind "Mehr" in the header - they are not part of the daily routine.
*/
const items = [ const items = [
{ href: '/', label: 'Start', icon: House },
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid }, { href: '/katalog', label: 'Katalog', icon: LayoutGrid },
{ href: '/sammlung', label: 'Sammlung', icon: Boxes }, { href: '/sammlung', label: 'Sammlung', icon: Boxes },
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift }, { href: '/wunschlisten', label: 'Wünsche', icon: Gift },
{ href: '/konto', label: 'Konto', icon: User },
] as const; ] as const;
export function BottomNav() { function isActive(pathname: string, href: string) {
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}
function NavLink({ item, pathname }: { item: (typeof items)[number]; pathname: string }) {
const active = isActive(pathname, item.href);
return (
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
{item.label}
</Link>
);
}
export function BottomNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -20,49 +55,32 @@ export function BottomNav() {
aria-label="Hauptnavigation" aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden" className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
> >
<ul className="mx-auto flex max-w-lg"> <ul className="mx-auto grid max-w-lg grid-cols-5 items-center">
{items.map((item) => { {items.slice(0, 2).map((item) => (
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); <li key={item.href}>
return ( <NavLink item={item} pathname={pathname} />
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active
? 'font-bold text-foreground'
: 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active
? 'border-edge bg-secondary text-secondary-foreground'
: 'border-transparent',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
{item.label}
</Link>
</li> </li>
); ))}
})} <li className="flex justify-center">
<AddMenu legoImport={legoImport} />
</li>
{items.slice(2).map((item) => (
<li key={item.href}>
<NavLink item={item} pathname={pathname} />
</li>
))}
</ul> </ul>
</nav> </nav>
); );
} }
export function DesktopNav() { export function DesktopNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex"> <nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
{items.map((item) => { {items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); const active = isActive(pathname, item.href);
return ( return (
<Link <Link
key={item.href} key={item.href}
@@ -80,6 +98,9 @@ export function DesktopNav() {
</Link> </Link>
); );
})} })}
<span className="ml-2">
<AddMenu legoImport={legoImport} variant="button" />
</span>
</nav> </nav>
); );
} }
@@ -1,78 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,90 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { chooseCollectorAction } from '@/app/(app)/collector-actions';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* "Für wen?" in the header: picks the child the whole app is looking at.
*
* Only rendered with more than one collector - with a single one the question
* does not exist. The choice is remembered per device; a `?sammler=` in the
* URL (a shared link) takes precedence and is shown as active.
*/
export function CollectorSwitch({
collectors,
storedId,
}: {
collectors: CollectorOption[];
storedId: string | null;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const fromUrl = searchParams.get('sammler');
const current = fromUrl === 'alle' ? '' : (fromUrl ?? storedId ?? '');
function select(id: string) {
startTransition(async () => {
const result = await chooseCollectorAction({ id });
if (!result.ok) {
toast.error(result.error);
return;
}
// The header choice replaces a collector that came in via the URL.
if (fromUrl !== null) {
const params = new URLSearchParams(searchParams.toString());
params.delete('sammler');
params.delete('seite');
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}
router.refresh();
});
}
const options = [{ id: '', name: 'Alle', color: '' }, ...collectors];
return (
<div role="radiogroup" aria-label="Für wen?" className="flex gap-2 overflow-x-auto pb-1">
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
role="radio"
aria-checked={active}
disabled={pending}
onClick={() => select(option.id)}
className={cn(
'flex h-10 shrink-0 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] px-3 text-sm font-bold transition-colors',
active
? 'border-edge bg-card text-card-foreground shadow-(--lift-sm)'
: 'border-transparent text-muted-foreground hover:bg-muted',
)}
>
<span
aria-hidden
className={cn(
'flex size-6 items-center justify-center rounded-md border-[length:var(--edge-w)] border-edge text-xs font-bold text-white',
option.id ? collectorDotClass(option.color) : 'bg-secondary text-secondary-foreground',
)}
>
{option.id ? option.name.charAt(0).toUpperCase() : '★'}
</span>
{option.name}
</button>
);
})}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import {
Bell,
Building2,
Database,
Mail,
MapPin,
Palette,
Tag,
UserRound,
Users,
type LucideIcon,
} from 'lucide-react';
/**
* Everything that is not part of the daily routine lives behind "Mehr":
* the account, the design, family settings and master data. One list feeds
* the "Mehr" page and the title bar of each of those pages.
*/
export type MoreGroup = 'ich' | 'familie' | 'katalog' | 'daten';
export type MoreItem = {
href: string;
label: string;
description: string;
group: MoreGroup;
icon: LucideIcon;
adminOnly: boolean;
};
export const MORE_GROUPS: Array<{ id: MoreGroup; label: string }> = [
{ id: 'ich', label: 'Ich' },
{ id: 'familie', label: 'Familie' },
{ id: 'katalog', label: 'Katalog pflegen' },
{ id: 'daten', label: 'Daten' },
];
export const MORE_ITEMS: MoreItem[] = [
{
href: '/konto',
label: 'Konto & Passkeys',
description: 'Profil, Anmeldung per Passkey.',
group: 'ich',
icon: UserRound,
adminOnly: false,
},
{
href: '/konto#design',
label: 'Design',
description: 'Wie Bricktrack auf diesem Gerät aussieht.',
group: 'ich',
icon: Palette,
adminOnly: false,
},
{
href: '/verwaltung/sammler',
label: 'Sammler',
description: 'Wer sammelt – jedes Kind mit eigener Sammlung und eigenen Wunschlisten.',
group: 'familie',
icon: Users,
adminOnly: false,
},
{
href: '/verwaltung/einladungen',
label: 'Einladungen',
description: 'Weitere Konten für die Familie.',
group: 'familie',
icon: Mail,
adminOnly: true,
},
{
href: '/verwaltung/erinnerungen',
label: 'Erinnerungen',
description: 'E-Mail, wenn ein Wunschlisten-Termin näher rückt.',
group: 'familie',
icon: Bell,
adminOnly: true,
},
{
href: '/verwaltung/hersteller',
label: 'Hersteller',
description: 'Die Marken, deren Sets im Katalog stehen.',
group: 'katalog',
icon: Building2,
adminOnly: false,
},
{
href: '/verwaltung/themen',
label: 'Themen',
description: 'Serien je Hersteller, auch verschachtelt.',
group: 'katalog',
icon: Tag,
adminOnly: false,
},
{
href: '/verwaltung/standorte',
label: 'Standorte',
description: 'Wo die Sets aufbewahrt werden.',
group: 'katalog',
icon: MapPin,
adminOnly: false,
},
{
href: '/verwaltung/daten',
label: 'Export & Import',
description: 'Bestand sichern, umziehen oder als Tabelle auswerten.',
group: 'daten',
icon: Database,
adminOnly: true,
},
];
export function moreItemsFor(role: string): MoreItem[] {
return MORE_ITEMS.filter((item) => !item.adminOnly || role === 'admin');
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Loader2, LogOut } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
export function SignOutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
async function signOut() {
setPending(true);
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<Button variant="outline" size="lg" onClick={() => void signOut()} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <LogOut />}
Abmelden
</Button>
);
}
-65
View File
@@ -1,65 +0,0 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { SessionUser } from '@/lib/session';
export function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
async function signOut() {
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<span className="block text-sm font-medium">{user.name}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserRound />
Konto und Passkeys
</Link>
</DropdownMenuItem>
{user.role === 'admin' ? (
<DropdownMenuItem asChild>
<Link href="/verwaltung/hersteller">
<Settings />
Verwaltung
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut()}>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,28 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { MORE_ITEMS } from '@/components/layout/more-items';
/** Title of a master-data page, with the way back to "Mehr". */
export function VerwaltungHeader() {
const pathname = usePathname();
const item = MORE_ITEMS.find((entry) => entry.href === pathname);
return (
<div className="space-y-2">
<Link
href="/mehr"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" aria-hidden />
Mehr
</Link>
<div>
<h1 className="text-2xl font-semibold tracking-tight">{item?.label ?? 'Verwaltung'}</h1>
{item ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
</div>
</div>
);
}
@@ -1,43 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{visible.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
import 'server-only';
import { cookies } from 'next/headers';
import { resolveCollectorId } from '@/lib/queries/collectors';
/**
* Which collector the app is looking at.
*
* The switch in the header remembers the choice per device (cookie), so Max's
* tablet opens on Max. A `?sammler=` in the URL still wins - shared links and
* the back button keep working - and `?sammler=alle` shows everybody.
*/
export const COLLECTOR_COOKIE = 'bricktrack-sammler';
export const ALL_COLLECTORS = 'alle';
export async function storedCollectorId(): Promise<string | undefined> {
return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value);
}
export async function activeCollectorId(param: string | undefined): Promise<string | undefined> {
if (param === ALL_COLLECTORS) return undefined;
if (param) return resolveCollectorId(param);
return storedCollectorId();
}