Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive, search and entry pages with image blocks. Admin area for brands, projects, post types, users, api clients, media and the entry editor with drag and drop images, preview per audience, scheduling and publish checks. Read and write API with bearer tokens, audience scoping, idempotent creation, OpenAPI document and editorial guide. Magic link login with configurable allowed domains, whole app behind the session gate. 456 tests including design rule checks.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
DATABASE_URL=postgres://logbuch:logbuch@localhost:5442/logbuch
|
||||||
|
DATABASE_URL_TEST=postgres://logbuch:logbuch@localhost:5442/logbuch_test
|
||||||
|
BETTER_AUTH_URL=http://localhost:4700
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
STORAGE_DIR=./storage
|
||||||
|
CRON_SECRET=
|
||||||
|
LOGIN_ALLOWED_DOMAINS=nyo.de,pocketrocket.de,pocket-rocket.io
|
||||||
|
SMTP_URL=
|
||||||
|
MAIL_FROM=logbuch@localhost
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.DS_Store
|
||||||
|
/storage
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:18
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: logbuch
|
||||||
|
POSTGRES_PASSWORD: logbuch
|
||||||
|
POSTGRES_DB: logbuch
|
||||||
|
ports:
|
||||||
|
- "5442:5432"
|
||||||
|
volumes:
|
||||||
|
- logbuch-db:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U logbuch"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
logbuch-db:
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
# Logbuch API
|
||||||
|
|
||||||
|
Stand: 31.07.2026. Diese Datei beschreibt den tatsächlichen Stand. Was noch nicht gebaut ist, steht am Ende unter "Noch nicht vorhanden" und ist dort auch so gekennzeichnet.
|
||||||
|
|
||||||
|
## Wofür Logbuch da ist
|
||||||
|
|
||||||
|
Logbuch hält fest, was in den Produkten der Firmengruppe passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag: eine neue Funktion, eine Verbesserung, ein behobener Fehler, eine Änderung mit Auswirkung.
|
||||||
|
|
||||||
|
Es ist kein Änderungsprotokoll für Entwickler. Es ist für Kollegen und später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen.
|
||||||
|
|
||||||
|
Die Anwendung besteht aus drei Teilen: dem Archiv zum Lesen, dem Adminbereich zum Schreiben, und dieser API. Über die API holen andere Anwendungen ihre Einträge und zeigen sie ihren Nutzern an, und über sie können Einträge verfasst werden.
|
||||||
|
|
||||||
|
## Grundlagen
|
||||||
|
|
||||||
|
Basis-Adresse lokal: `http://localhost:4700`
|
||||||
|
|
||||||
|
Alle Endpunkte liegen unter `/api/v1`. Angemeldet wird sich mit einem Token im Kopf:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Zugänge werden im Adminbereich unter Verwaltung, Zugänge angelegt. Beim Anlegen wird das Token einmal im Klartext gezeigt, danach nie wieder. Gespeichert ist nur ein Hash.
|
||||||
|
|
||||||
|
Ein Zugang hat drei Eigenschaften, die zusammen bestimmen, was er darf:
|
||||||
|
|
||||||
|
| Eigenschaft | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| Modus | `read` liest, `write` schreibt zusätzlich |
|
||||||
|
| Zielgruppe | `internal`, `customer` oder `public`. Bestimmt, welche Einträge der Zugang überhaupt sieht |
|
||||||
|
| Projektbindung | Ist ein Projekt gesetzt, sieht und schreibt der Zugang ausschließlich dort |
|
||||||
|
|
||||||
|
Die Zielgruppe ist die wichtigste Einstellung. Ein Zugang mit `customer` sieht Kundenbeiträge und öffentliche, aber niemals interne. Das gilt serverseitig, kein Parameter kann das umgehen.
|
||||||
|
|
||||||
|
Ein Zugang mit Schreibrecht kann **niemals veröffentlichen**. Alles landet als Entwurf, ein Mensch gibt frei.
|
||||||
|
|
||||||
|
Fehler kommen einheitlich als Problem-JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "unauthorized", "title": "unauthorized", "status": 401, "detail": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lesen
|
||||||
|
|
||||||
|
### GET /api/v1/projects
|
||||||
|
|
||||||
|
Die Projekte, die der Zugang sehen darf.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
http://localhost:4700/api/v1/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{ "items": [ { "id", "slug", "name", "code", "color", "description", "sort", "isActive" } ] }`
|
||||||
|
|
||||||
|
### GET /api/v1/posts
|
||||||
|
|
||||||
|
Die veröffentlichten Einträge, neueste zuerst.
|
||||||
|
|
||||||
|
| Parameter | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `project` | Slug eines Projekts |
|
||||||
|
| `since` | Zeitpunkt, ab dem gesucht wird |
|
||||||
|
| `type` | `feature`, `improvement`, `fix`, `breaking`, `info` |
|
||||||
|
| `tag` | Schlagwort |
|
||||||
|
| `page` | Seite, ab 1 |
|
||||||
|
| `per_page` | 1 bis 100, Standard 25 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:4700/api/v1/posts?project=trakk&per_page=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{ "items": [...], "meta": { "total", "page", "per_page" } }`
|
||||||
|
|
||||||
|
Ein Eintrag in der Liste trägt: `id`, `slug`, `title`, `teaser`, `type`, `audience`, `publishAt`, `number`, `projectSlug`, `projectName`, `projectCode`, `projectColor`.
|
||||||
|
|
||||||
|
### GET /api/v1/posts/:slug
|
||||||
|
|
||||||
|
Ein einzelner Eintrag. Ist er für die Zielgruppe des Zugangs nicht sichtbar, kommt 404, nicht 403. Damit verrät die Antwort nicht, dass es ihn gibt.
|
||||||
|
|
||||||
|
### GET /api/v1/unread
|
||||||
|
|
||||||
|
Was ein Nutzer der einbindenden Anwendung noch nicht gelesen hat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:4700/api/v1/unread?external_user_id=u-42"
|
||||||
|
```
|
||||||
|
|
||||||
|
`external_user_id` ist die Nutzerkennung der aufrufenden Anwendung, nicht die eines Logbuch-Kontos. Antwort: `{ "count", "items": [...] }`.
|
||||||
|
|
||||||
|
Der Zugang muss dafür an ein Projekt gebunden sein, sonst 422.
|
||||||
|
|
||||||
|
### POST /api/v1/read
|
||||||
|
|
||||||
|
Markiert Einträge als gelesen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"external_user_id":"u-42","post_ids":["<id>"]}' \
|
||||||
|
http://localhost:4700/api/v1/read
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{ "marked": 1 }`, also die Zahl der tatsächlich neu vermerkten Einträge. Unbekannte Kennungen und Einträge fremder Projekte werden still verworfen, die Antwort bleibt 200.
|
||||||
|
|
||||||
|
## Bilder
|
||||||
|
|
||||||
|
### POST /api/v1/media
|
||||||
|
|
||||||
|
Lädt ein Bild hoch, erzeugt WebP-Varianten und legt einen Datensatz an. Braucht einen Zugang mit Schreibrecht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "file=@screenshot.png" \
|
||||||
|
-F "projectId=<projekt-id>" \
|
||||||
|
-F "alt=Spaltenmenü mit den neuen Einträgen" \
|
||||||
|
http://localhost:4700/api/v1/media
|
||||||
|
```
|
||||||
|
|
||||||
|
Grenzen: nur Bildformate, höchstens 15 MB.
|
||||||
|
|
||||||
|
### GET /api/v1/media/file/...
|
||||||
|
|
||||||
|
Liefert eine Variante aus. Die Pfade stehen am Medium unter `variants`, je Eintrag mit `width`, `format` und `path`.
|
||||||
|
|
||||||
|
## Redaktionsleitfaden
|
||||||
|
|
||||||
|
Wer über die API schreibt, schreibt für Menschen. Diese Regeln gelten für jeden Eintrag.
|
||||||
|
|
||||||
|
**Aufbau.** Titel, Anreißer, dann der Text. Der Titel sagt, was sich geändert hat, nicht dass sich etwas geändert hat. Der Anreißer fasst in zwei bis drei Sätzen zusammen, was der Leser davon hat. Der Text erklärt, was vorher war, was jetzt ist, und was zu tun bleibt.
|
||||||
|
|
||||||
|
**Gute Titel:**
|
||||||
|
|
||||||
|
- "SLA-Uhr zählt Feiertage nicht mehr mit"
|
||||||
|
- "Spalten per Rechtsklick verwalten"
|
||||||
|
|
||||||
|
**Schlechte Titel:**
|
||||||
|
|
||||||
|
- "Verbesserungen an der Tabelle" (sagt nichts)
|
||||||
|
- "Wir haben ein spannendes neues Feature gebaut" (Werbung, keine Information)
|
||||||
|
|
||||||
|
**Ton.** Sachlich und direkt. Aktive Verben. Keine Werbesprache, keine Superlative, keine Ausrufezeichen, keine Emojis. Keine Gedankenstriche, immer normales Minus. Echte Umlaute.
|
||||||
|
|
||||||
|
**Länge.** So lang wie nötig. Meist drei bis sechs Absätze. Ein Fehlerbehebung darf auch aus zwei Sätzen bestehen.
|
||||||
|
|
||||||
|
**Bilder.** Zeigen, was sich geändert hat, nicht das Firmenlogo. Jedes Bild bekommt einen Alt-Text, der beschreibt was zu sehen ist. Bei öffentlichen Beiträgen ist der Alt-Text Pflicht, sonst lässt sich der Beitrag nicht veröffentlichen.
|
||||||
|
|
||||||
|
**Zielgruppe.** `internal` für alles, was intern bleibt: Umbauten, Zwischenstände, Interna über Kunden. `customer` für alles, was ein Kunde des Produkts wissen soll. `public` nur für das, was auch außerhalb stehen darf.
|
||||||
|
|
||||||
|
**Wer veröffentlicht.** Nicht der Zugang. Ein Mensch schaut drauf und gibt frei. Ein über die API verfasster Beitrag ist immer ein Vorschlag.
|
||||||
|
|
||||||
|
## Noch nicht vorhanden
|
||||||
|
|
||||||
|
Ehrlich benannt, damit niemand daran vorbei entwickelt. Diese Endpunkte werden gerade gebaut:
|
||||||
|
|
||||||
|
| Geplant | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/posts` | Beitrag als Entwurf anlegen |
|
||||||
|
| `PATCH /api/v1/posts/:id` | Entwurf ändern |
|
||||||
|
| `PUT /api/v1/posts/:id/blocks` | Inhaltsblöcke setzen |
|
||||||
|
| `GET /api/v1/posts/:id/preview` | Entwurf zurücklesen |
|
||||||
|
| `DELETE /api/v1/posts/:id` | Entwurf löschen |
|
||||||
|
| `GET /api/v1/post-types` | verwaltbare Beitragsarten |
|
||||||
|
| `GET /api/v1/style-guide` | dieser Leitfaden, maschinenlesbar |
|
||||||
|
| `GET /api/v1/openapi.json` | maschinenlesbare Beschreibung |
|
||||||
|
| `/admin/api` | diese Dokumentation im Adminbereich |
|
||||||
|
|
||||||
|
Solange sie fehlen, kann ein Zugang mit Schreibrecht ausschließlich Bilder hochladen.
|
||||||
|
|
||||||
|
## Blocktypen
|
||||||
|
|
||||||
|
Der Inhalt eines Beitrags besteht aus Blöcken in fester Reihenfolge.
|
||||||
|
|
||||||
|
| Typ | Daten |
|
||||||
|
|---|---|
|
||||||
|
| `text` | `text` |
|
||||||
|
| `image` | `mediaId` |
|
||||||
|
| `gallery` | `mediaIds` |
|
||||||
|
| `before_after` | `beforeMediaId`, `afterMediaId` |
|
||||||
|
| `video` | `url`, `title` |
|
||||||
|
| `quote` | `text`, `source` |
|
||||||
|
| `code` | `code`, `language` |
|
||||||
|
| `link` | `url`, `title`, `description` |
|
||||||
|
| `callout` | `text`, `tone` |
|
||||||
|
|
||||||
|
Unbekannte Typen werden bei der Anzeige übersprungen, sie zerlegen die Seite nicht.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Logbuch Gestaltungsrichtung
|
||||||
|
|
||||||
|
Stand: 30.07.2026. Diese Datei ist verbindlich. Jede Farb-, Schrift- und Abstandsentscheidung wird hieraus abgeleitet, nichts wird zusätzlich erfunden.
|
||||||
|
|
||||||
|
## Warum der erste Entwurf nicht trug
|
||||||
|
|
||||||
|
Der erste Entwurf bestand aus Haarlinien, Mono-Kleinkram und einer sehr großen Überschrift. Er funktionierte nur unter der Annahme, dass jeder Eintrag ein Bild mitbringt. Ohne Bilder blieb eine graue Liste ohne Halt. Die Gestaltung muss vollständig ohne Bildmaterial tragen und mit Bildmaterial besser werden, nicht umgekehrt.
|
||||||
|
|
||||||
|
## Leitgedanke
|
||||||
|
|
||||||
|
Logbuch zeigt Meldungen aus einer Flotte von Projekten. Die wichtigste Information eines Eintrags ist nicht sein Datum, sondern **von welchem Projekt er kommt**. Deshalb trägt die Projektfarbe die Seite.
|
||||||
|
|
||||||
|
## Signaturelement: die Kennplatte
|
||||||
|
|
||||||
|
Ein massives Farbfeld in der Projektfarbe mit dem Projektkürzel und der laufenden Eintragsnummer, gesetzt wie eine Rumpf- oder Containerbeschriftung.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ TRK │
|
||||||
|
│ 0142 │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Kürzel aus `project.code`, Rückfall auf die ersten drei Buchstaben des Slugs in Großbuchstaben
|
||||||
|
- Nummer immer vierstellig mit führenden Nullen
|
||||||
|
- Schrift: Mono, 600, weite Laufweite beim Kürzel, sehr groß bei der Nummer
|
||||||
|
- Textfarbe auf der Platte: immer Papierfarbe, nie Tinte
|
||||||
|
- In der Liste als schmale Variante, im Aufmacher als große Fläche
|
||||||
|
|
||||||
|
Die Platte ist der einzige Ort, an dem kräftige Farbe vorkommt. Alles andere bleibt ruhig.
|
||||||
|
|
||||||
|
## Farbe
|
||||||
|
|
||||||
|
Grundpalette, hell:
|
||||||
|
|
||||||
|
| Token | Wert | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `paper` | `#f2f1ec` | Seitenhintergrund |
|
||||||
|
| `surface` | `#fbfaf6` | abgesetzte Flächen |
|
||||||
|
| `ink` | `#14161a` | Titel, Fließtext |
|
||||||
|
| `ink-2` | `#4c5157` | Anreißer, Sekundärtext |
|
||||||
|
| `ink-3` | `#8a8f95` | Beschriftungen, Datum |
|
||||||
|
| `rule` | `#dedcd3` | Trennlinien |
|
||||||
|
| `signal` | `#c43a22` | ausschließlich für Neu-Markierung und die Randlinie |
|
||||||
|
|
||||||
|
Dunkel, dieselben Tokennamen:
|
||||||
|
|
||||||
|
| Token | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `paper` | `#121417` |
|
||||||
|
| `surface` | `#191c20` |
|
||||||
|
| `ink` | `#edebe4` |
|
||||||
|
| `ink-2` | `#a9aeb4` |
|
||||||
|
| `ink-3` | `#757a80` |
|
||||||
|
| `rule` | `#2a2e34` |
|
||||||
|
| `signal` | `#e4603f` |
|
||||||
|
|
||||||
|
Projektfarben kommen aus der Datenbank, `project.color`. Sie werden nur auf Kennplatten und als Balken verwendet, niemals als Textfarbe auf Papier. Im dunklen Modus wird die Plattenfarbe über `color-mix` um 12 Prozent zur Papierfarbe abgedunkelt, damit sie nicht leuchtet.
|
||||||
|
|
||||||
|
Verboten: Farbverläufe, Schlagschatten außer einem einzigen für den Aufmacher, farbige Titel. Titel sind immer `ink`, auch wenn sie Verweise sind. Verweisblau gilt nur für echte Verweise im Fließtext.
|
||||||
|
|
||||||
|
## Schrift
|
||||||
|
|
||||||
|
Selbst gehostet über `next/font/google`, keine Systemschriften mehr, damit es auf jedem Rechner gleich aussieht.
|
||||||
|
|
||||||
|
| Rolle | Schrift | Einsatz |
|
||||||
|
|---|---|---|
|
||||||
|
| Display | Archivo, 600 und 700, Laufweite -0.02em | Titel, Wortmarke, Abschnittsköpfe |
|
||||||
|
| Fließtext | Source Serif 4, 400, Zeilenhöhe 1.6 | Anreißer, Artikeltext |
|
||||||
|
| Technisch | JetBrains Mono, 500 | Kennplatten, Datum, Nummern, Beschriftungen |
|
||||||
|
|
||||||
|
Skala in rem, keine Zwischenwerte erfinden:
|
||||||
|
|
||||||
|
| Stufe | Größe | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| micro | 0.6875 | Beschriftungen in Versalien |
|
||||||
|
| small | 0.8125 | Datum, Metazeilen |
|
||||||
|
| base | 1.0625 | Fließtext |
|
||||||
|
| lead | 1.25 | Anreißer im Aufmacher, Titel in der Liste |
|
||||||
|
| title | 1.75 | Abschnittsköpfe, Titel auf Projektseiten |
|
||||||
|
| hero | clamp(2.25, 4vw, 3.25) | Aufmachertitel, Seitentitel |
|
||||||
|
| plate | clamp(2, 5vw, 3.5) | Nummer auf der großen Kennplatte |
|
||||||
|
|
||||||
|
## Nachtrag 30.07.2026: Aufbau nach dem Vergleich
|
||||||
|
|
||||||
|
Ein zweiter Entwurf hat drei Dinge besser gelöst. Sie sind ab sofort verbindlich und stechen die Skizze weiter unten.
|
||||||
|
|
||||||
|
**Feste Seitenleiste statt Filterzeile.** Links eine schmale Spalte über die volle Höhe: Wortmarke mit dem Stand des Logbuchs, Suche, die Einträge Übersicht und Archiv, dann alle Projekte mit Farbpunkt, gruppiert nach Marke. Unten der Hell-Dunkel-Umschalter. Die Projekte sind Daten, die Leiste wächst mit ihnen. Ab Tablettbreite klappt sie zu einer Kopfzeile mit Schublade zusammen.
|
||||||
|
|
||||||
|
**Der Aufmacher trägt ein Bild.** Volle Breite des Inhaltsbereichs, darunter Projektzeile, Titel, Anreißer, Autor. Die Eintragsnummer steht sehr groß und sehr blass hinter dem Text, als Wasserzeichen. Ohne Bild übernimmt die Kennplattenfläche denselben Platz, das Wasserzeichen bleibt.
|
||||||
|
|
||||||
|
**Zweite Spalte rechts.** Neben dem Aufmacher stehen die zwei bis drei nächsten Einträge als kleine Karten mit Vorschaubild, Kürzel, Art, Titel und Anreißerbeginn.
|
||||||
|
|
||||||
|
**Zeilen zeigen mehr.** Eine Listenzeile trägt Kürzel und Nummer, die Art, den Titel, den Anreißer in einer Zeile, rechts Datum und Autor. Nicht nur Titel und Datum.
|
||||||
|
|
||||||
|
**Kürzel-Schreibweise.** Kürzel und Nummer werden als `TRK-0142` gesetzt, mit Bindestrich, nicht als zwei getrennte Angaben.
|
||||||
|
|
||||||
|
## Aufbau der Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
|
│ LOGBUCH alle · trakk · mta360 · ... suchen ☀ │ schmal, klebend
|
||||||
|
├───────────────────────────────────────────────────────────────┤
|
||||||
|
│ ZULETZT 7 Einträge · 14 Tage │ Augenbraue
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────┬───────────────────────────────────────────┐ │
|
||||||
|
│ │ TRK │ FEATURE NEU │ │ Aufmacher
|
||||||
|
│ │ │ Regel-Engine: Bedingung trifft Aktion │ │
|
||||||
|
│ │ 0142 │ Anreißer in Serifenschrift, zwei Zeilen │ │
|
||||||
|
│ │ │ 30.07.2026 · Paul · Trakk │ │
|
||||||
|
│ └───────────────┴───────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ── ÄLTERE EINTRÄGE ────────────────────────────────────── │
|
||||||
|
│ ▌MTA 0141 Spalten per Rechtsklick verwalten 29.07. │ dichte Zeilen
|
||||||
|
│ ▌TEL 0140 10.000 Tracker auf einer Karte 28.07. │
|
||||||
|
│ ▌TRK 0139 SLA-Uhr zählt Feiertage nicht mit 25.07. │
|
||||||
|
│ │
|
||||||
|
│ ── ARCHIV ─────────────────────────────────────────────── │
|
||||||
|
│ 2026 [Jan 14] [Feb 11] [Mär 18] ... │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Aufmacher ist genau ein Eintrag, der neueste. Alles darunter ist dicht und zum Überfliegen gebaut: eine Zeile pro Eintrag, links ein Balken in der Projektfarbe, dann Kürzel und Nummer in Mono, dann der Titel in Display, rechts das Datum. Bei Überfahren wächst der Balken und der Titel rückt zwei Pixel nach rechts.
|
||||||
|
|
||||||
|
Sechs Einträge müssen ohne Scrollen sichtbar sein. Der erste Entwurf brauchte für vier Einträge einen ganzen Bildschirm, das war der Kern des Problems.
|
||||||
|
|
||||||
|
## Aufbau der Beitragsseite
|
||||||
|
|
||||||
|
Reihenfolge: Kennplatte klein und Projektzeile, dann Titel, dann Anreißer als Lead in `lead`, dann der Inhalt in einer Lesespalte von 38rem. Metadaten stehen **nicht** zwischen Titel und Text, sondern als eine ruhige Zeile unter dem Titel und ausführlich am Fuß des Artikels.
|
||||||
|
|
||||||
|
Der Anreißer wird nie doppelt gezeigt. Wenn der erste Inhaltsblock denselben Text enthält, wird der Block übersprungen.
|
||||||
|
|
||||||
|
## Fehlende Bilder
|
||||||
|
|
||||||
|
Bilder gibt es noch nicht. Ein Eintrag ohne Bild bekommt deshalb im Aufmacher die große Kennplatte als Fläche, nicht einen leeren Rahmen und keinen Platzhalter mit Kamerasymbol. Sobald ein Beitrag ein Aufmacherbild hat, tritt die Platte auf die schmale Variante zurück und das Bild übernimmt die Fläche.
|
||||||
|
|
||||||
|
## Bewegung
|
||||||
|
|
||||||
|
Sparsam. Beim Laden erscheinen die Listenzeilen versetzt um je 40 Millisekunden mit 6 Pixel Versatz. Beim Überfahren wächst der Farbbalken. Sonst nichts. Bei `prefers-reduced-motion` entfällt alles.
|
||||||
|
|
||||||
|
## Sorgfalt im Detail
|
||||||
|
|
||||||
|
Der Auftraggeber hat den ersten Bau nicht als kaputt bezeichnet, sondern als lieblos. Das ist die genauere Kritik. Struktur allein reicht nicht, die folgenden Kleinigkeiten sind Pflicht, nicht Kür:
|
||||||
|
|
||||||
|
- **Zahlen laufen tabellarisch.** `font-variant-numeric: tabular-nums` überall, wo Nummern und Daten untereinander stehen. Eine Liste, in der die Nummern zittern, sieht ungepflegt aus.
|
||||||
|
- **Datum spricht wie ein Mensch.** Jünger als sieben Tage: "vor drei Tagen". Älter: das Datum. Im Titelattribut immer das genaue Datum.
|
||||||
|
- **Lesedauer** je Beitrag, geschätzt aus der Textmenge, in der Metazeile. Sagt dem Leser, worauf er sich einlässt.
|
||||||
|
- **Autor als Kürzel-Marke**, zwei Buchstaben in einem kleinen Feld in Tintenfarbe. Kein Bild, keine leere Silhouette.
|
||||||
|
- **Leerzustände sagen, was als Nächstes zu tun ist.** Nicht "Keine Einträge", sondern was der Leser jetzt tun kann, mit einem Weg dorthin.
|
||||||
|
- **Trennlinien nur zwischen Gleichartigem.** Der letzte Eintrag einer Liste bekommt keine Linie nach unten.
|
||||||
|
- **Überschriften brechen ausgeglichen**, `text-wrap: balance` für Titel, `pretty` für Fließtext. Keine einzelnen Wörter in der letzten Zeile.
|
||||||
|
- **Textauswahl** trägt die Signalfarbe, nicht das Browserblau.
|
||||||
|
- **Sichtbarer Tastaturfokus** in Signalfarbe, nicht der Standardrahmen des Browsers.
|
||||||
|
- **Überfahren fühlt sich an**: der Farbbalken wächst, der Titel rückt zwei Pixel, beides in 120 Millisekunden.
|
||||||
|
- **Die 404-Seite** ist gestaltet und trägt eine eigene Zeile, keine Standardmeldung.
|
||||||
|
- **Der Aufmacher bekommt genau einen Schatten**, sehr weich, damit er von der Fläche abhebt. Sonst gibt es im ganzen Entwurf keine Schatten.
|
||||||
|
|
||||||
|
Wenn eine dieser Kleinigkeiten fehlt, ist die Arbeit nicht fertig, auch wenn die Seite lädt.
|
||||||
|
|
||||||
|
## Qualitätsboden
|
||||||
|
|
||||||
|
Bis 22rem Breite benutzbar, sichtbarer Tastaturfokus, Kontrast mindestens 4.5 zu 1 für Fließtext, keine nativen Scrollbars, hell und dunkel aus denselben Tokens.
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Logbuch</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--paper: #eff0ec;
|
||||||
|
--surface: #fbfbf9;
|
||||||
|
--ink: #191c20;
|
||||||
|
--ink-2: #4a4f56;
|
||||||
|
--ink-3: #868b92;
|
||||||
|
--rule: #d8d9d2;
|
||||||
|
--red: #c43a22;
|
||||||
|
--blue: #2b4a9b;
|
||||||
|
--shot-1: #dcdcd5;
|
||||||
|
--shot-2: #c9cac2;
|
||||||
|
|
||||||
|
--font-display: "Futura", "Avenir Next Condensed", "Avenir Next", system-ui, sans-serif;
|
||||||
|
--font-body: "Charter", "Iowan Old Style", Palatino, Georgia, serif;
|
||||||
|
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
|
||||||
|
|
||||||
|
--margin-col: 6rem;
|
||||||
|
--gap: 1.5rem;
|
||||||
|
--maxw: 63rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--paper: #15171a;
|
||||||
|
--surface: #1c1f23;
|
||||||
|
--ink: #e9e8e3;
|
||||||
|
--ink-2: #a8adb4;
|
||||||
|
--ink-3: #73787f;
|
||||||
|
--rule: #2c3036;
|
||||||
|
--red: #e46045;
|
||||||
|
--blue: #8aa6ee;
|
||||||
|
--shot-1: #2a2e34;
|
||||||
|
--shot-2: #383d45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper: #15171a;
|
||||||
|
--surface: #1c1f23;
|
||||||
|
--ink: #e9e8e3;
|
||||||
|
--ink-2: #a8adb4;
|
||||||
|
--ink-3: #73787f;
|
||||||
|
--rule: #2c3036;
|
||||||
|
--red: #e46045;
|
||||||
|
--blue: #8aa6ee;
|
||||||
|
--shot-1: #2a2e34;
|
||||||
|
--shot-2: #383d45;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--paper: #eff0ec;
|
||||||
|
--surface: #fbfbf9;
|
||||||
|
--ink: #191c20;
|
||||||
|
--ink-2: #4a4f56;
|
||||||
|
--ink-3: #868b92;
|
||||||
|
--rule: #d8d9d2;
|
||||||
|
--red: #c43a22;
|
||||||
|
--blue: #2b4a9b;
|
||||||
|
--shot-1: #dcdcd5;
|
||||||
|
--shot-2: #c9cac2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
h1, h2, h3, .label, .btn, .badge, .stamp { font-family: var(--font-display); }
|
||||||
|
.label, .meta, .num { font-family: var(--font-mono); }
|
||||||
|
a { color: var(--blue); text-decoration-thickness: 1px; text-underline-offset: 0.15em; }
|
||||||
|
:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||||||
|
::-webkit-scrollbar { width: 0.5rem; height: 0.5rem; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--ink-3); border-radius: 1rem; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 1.5rem; }
|
||||||
|
|
||||||
|
/* Kopfleiste */
|
||||||
|
.bar {
|
||||||
|
position: sticky; top: 0; z-index: 5;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: color-mix(in srgb, var(--paper) 88%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem; font-weight: 500;
|
||||||
|
letter-spacing: 0.42em; text-transform: uppercase;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.mark span { color: var(--red); }
|
||||||
|
.btn {
|
||||||
|
font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
padding: 0.45rem 0.8rem; border: 1px solid var(--rule);
|
||||||
|
background: var(--surface); color: var(--ink); border-radius: 0.125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--ink-3); }
|
||||||
|
.btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||||
|
.btn--primary:hover { background: var(--red); border-color: var(--red); }
|
||||||
|
|
||||||
|
/* Formularkopf */
|
||||||
|
.masthead { padding: 3.5rem 0 2.5rem; }
|
||||||
|
.masthead h1 {
|
||||||
|
font-size: clamp(2.5rem, 7vw, 4.5rem);
|
||||||
|
font-weight: 500; line-height: 0.95; letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.masthead p {
|
||||||
|
margin: 0 0 2.25rem; max-width: 34rem;
|
||||||
|
color: var(--ink-2); font-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
.fields { display: grid; gap: 0.4rem; }
|
||||||
|
.field { display: flex; align-items: baseline; gap: 0.6rem; font-size: 0.75rem; }
|
||||||
|
.field .label { color: var(--ink-3); letter-spacing: 0.16em; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
.field .leader { flex: 1; border-bottom: 1px dotted var(--rule); }
|
||||||
|
.field .val { font-family: var(--font-mono); color: var(--ink-2); text-align: right; }
|
||||||
|
|
||||||
|
/* Filterzeile */
|
||||||
|
.filters { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1.75rem 0 0.5rem; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||||
|
font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.06em;
|
||||||
|
padding: 0.35rem 0.65rem; border: 1px solid var(--rule); border-radius: 1rem;
|
||||||
|
background: transparent; color: var(--ink-2); cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||||
|
.chip i { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--dot, var(--ink-3)); }
|
||||||
|
|
||||||
|
/* Logbuch-Schiene */
|
||||||
|
.log { position: relative; padding: 1rem 0 3rem; }
|
||||||
|
.log::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0;
|
||||||
|
left: calc(var(--margin-col) - 0.5rem); width: 1px;
|
||||||
|
background: color-mix(in srgb, var(--red) 45%, transparent);
|
||||||
|
}
|
||||||
|
.entry {
|
||||||
|
display: grid; grid-template-columns: var(--margin-col) 1fr; gap: var(--gap);
|
||||||
|
padding: 1.75rem 0; border-bottom: 1px solid var(--rule);
|
||||||
|
animation: rise 0.5s both cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
animation-delay: calc(var(--i, 0) * 60ms);
|
||||||
|
}
|
||||||
|
.entry:last-child { border-bottom: 0; }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(0.5rem); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .entry { animation: none; } }
|
||||||
|
|
||||||
|
.entry .rail { position: relative; text-align: right; }
|
||||||
|
.entry .rail::after {
|
||||||
|
content: ""; position: absolute; right: -0.75rem; top: 0.45rem;
|
||||||
|
width: 0.5rem; height: 0.5rem;
|
||||||
|
border: 1px solid var(--red); background: var(--paper);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.entry:hover .rail::after { background: var(--red); }
|
||||||
|
.entry--unread .rail::after { background: var(--red); }
|
||||||
|
.entry .date { display: block; font-family: var(--font-mono); font-size: 0.8125rem; color: var(--ink); }
|
||||||
|
.entry .year, .entry .num { display: block; font-family: var(--font-mono); font-size: 0.6875rem; color: var(--ink-3); }
|
||||||
|
.entry .num { margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.head { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.badge {
|
||||||
|
font-size: 0.625rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
padding: 0.2rem 0.45rem; border: 1px solid currentColor; color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.badge--feature { color: var(--blue); }
|
||||||
|
.badge--breaking { color: var(--red); }
|
||||||
|
.stamp {
|
||||||
|
margin-left: auto; transform: rotate(-5deg);
|
||||||
|
font-size: 0.6875rem; letter-spacing: 0.22em; text-transform: uppercase;
|
||||||
|
color: var(--red); border: 0.125rem solid var(--red); padding: 0.15rem 0.5rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.entry h2 { margin: 0 0 0.5rem; font-size: 1.375rem; font-weight: 500; line-height: 1.2; }
|
||||||
|
.entry--lead h2 { font-size: clamp(1.75rem, 3.6vw, 2.5rem); }
|
||||||
|
.entry p { margin: 0; color: var(--ink-2); max-width: 40rem; }
|
||||||
|
.foot {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; margin-top: 0.9rem;
|
||||||
|
font-family: var(--font-mono); font-size: 0.6875rem; color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.foot .proj { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--ink-2); }
|
||||||
|
.foot .proj i { width: 0.5rem; height: 0.5rem; background: var(--dot); }
|
||||||
|
.foot .sep { color: var(--rule); }
|
||||||
|
|
||||||
|
/* Bildplatzhalter */
|
||||||
|
.shots { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
.shot {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
background: var(--surface); border: 1px solid var(--rule); border-radius: 0.1875rem;
|
||||||
|
flex: 1; aspect-ratio: 16 / 10;
|
||||||
|
}
|
||||||
|
.shot--wide { aspect-ratio: 21 / 9; }
|
||||||
|
.shot::before {
|
||||||
|
content: ""; position: absolute; inset: 0.5rem 0.5rem auto; height: 0.375rem;
|
||||||
|
background: var(--shot-2); border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
.shot--table::after {
|
||||||
|
content: ""; position: absolute; inset: 1.4rem 0.5rem 0.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 0 / 62% 0.3rem no-repeat,
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 0.75rem / 88% 0.3rem no-repeat,
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 1.5rem / 45% 0.3rem no-repeat,
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 2.25rem / 74% 0.3rem no-repeat,
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 3rem / 55% 0.3rem no-repeat;
|
||||||
|
}
|
||||||
|
.shot--chart::after {
|
||||||
|
content: ""; position: absolute; inset: auto 0.5rem 0.5rem; height: 55%;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--shot-2) 0 0) 0 100% / 12% 40% no-repeat,
|
||||||
|
linear-gradient(var(--shot-2) 0 0) 22% 100% / 12% 72% no-repeat,
|
||||||
|
linear-gradient(var(--shot-2) 0 0) 44% 100% / 12% 55% no-repeat,
|
||||||
|
linear-gradient(var(--shot-2) 0 0) 66% 100% / 12% 90% no-repeat,
|
||||||
|
linear-gradient(var(--shot-2) 0 0) 88% 100% / 12% 30% no-repeat;
|
||||||
|
}
|
||||||
|
.shot--map::after {
|
||||||
|
content: ""; position: absolute; inset: 1.4rem 0.5rem 0.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 0.15rem, var(--red) 98%, transparent) 18% 30% / 100% 100% no-repeat,
|
||||||
|
radial-gradient(circle 0.15rem, var(--red) 98%, transparent) 42% 62% / 100% 100% no-repeat,
|
||||||
|
radial-gradient(circle 0.15rem, var(--red) 98%, transparent) 71% 24% / 100% 100% no-repeat,
|
||||||
|
radial-gradient(circle 0.15rem, var(--shot-2) 98%, transparent) 58% 80% / 100% 100% no-repeat,
|
||||||
|
radial-gradient(circle 0.15rem, var(--shot-2) 98%, transparent) 86% 58% / 100% 100% no-repeat,
|
||||||
|
linear-gradient(var(--shot-1) 0 0) 0 45% / 100% 1px no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archiv */
|
||||||
|
.section-label {
|
||||||
|
display: flex; align-items: center; gap: 1rem; margin: 0 0 1.25rem;
|
||||||
|
font-family: var(--font-display); font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.24em; text-transform: uppercase; color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.section-label::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
|
||||||
|
.archive { padding: 2.5rem 0; border-top: 1px solid var(--rule); }
|
||||||
|
.year { display: grid; grid-template-columns: 4rem 1fr; gap: var(--gap); margin-bottom: 1.25rem; }
|
||||||
|
.year > .label { font-family: var(--font-mono); font-size: 0.8125rem; color: var(--ink-3); }
|
||||||
|
.months { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||||
|
.month {
|
||||||
|
display: flex; align-items: baseline; gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.55rem; border: 1px solid var(--rule); border-radius: 0.125rem;
|
||||||
|
font-family: var(--font-mono); font-size: 0.7rem; color: var(--ink-2);
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
}
|
||||||
|
.month:hover { border-color: var(--red); color: var(--ink); }
|
||||||
|
.month b { font-weight: 400; color: var(--ink-3); font-size: 0.625rem; }
|
||||||
|
|
||||||
|
/* In-App-Vorschau */
|
||||||
|
.inapp { padding: 2.5rem 0 4rem; border-top: 1px solid var(--rule); }
|
||||||
|
.inapp .hint { color: var(--ink-2); max-width: 34rem; margin: 0 0 1.5rem; font-size: 0.9375rem; }
|
||||||
|
.panel {
|
||||||
|
max-width: 24rem; background: var(--surface);
|
||||||
|
border: 1px solid var(--rule); border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 1.5rem 3rem -1.5rem color-mix(in srgb, var(--ink) 25%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel header {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0.875rem; border-bottom: 1px solid var(--rule);
|
||||||
|
font-family: var(--font-display); font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.panel header .count {
|
||||||
|
font-family: var(--font-mono); font-size: 0.625rem;
|
||||||
|
background: var(--red); color: #fff; padding: 0.1rem 0.35rem; border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.panel header .close { margin-left: auto; color: var(--ink-3); font-family: var(--font-mono); }
|
||||||
|
.panel ul { list-style: none; margin: 0; padding: 0; max-height: 15rem; overflow-y: auto; }
|
||||||
|
.panel li { padding: 0.75rem 0.875rem; border-bottom: 1px solid var(--rule); }
|
||||||
|
.panel li:last-child { border-bottom: 0; }
|
||||||
|
.panel li .meta { font-size: 0.625rem; color: var(--ink-3); letter-spacing: 0.06em; }
|
||||||
|
.panel li strong { display: block; font-family: var(--font-display); font-weight: 500; font-size: 0.9375rem; margin: 0.2rem 0; }
|
||||||
|
.panel li p { margin: 0; font-size: 0.8125rem; color: var(--ink-2); }
|
||||||
|
.panel footer { display: flex; gap: 0.5rem; padding: 0.75rem 0.875rem; border-top: 1px solid var(--rule); }
|
||||||
|
|
||||||
|
footer.site {
|
||||||
|
padding: 1.5rem 0 3rem; border-top: 1px solid var(--rule);
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center;
|
||||||
|
font-family: var(--font-mono); font-size: 0.6875rem; color: var(--ink-3);
|
||||||
|
}
|
||||||
|
footer.site .mark { font-size: 0.7rem; letter-spacing: 0.3em; margin-right: auto; color: var(--ink-2); }
|
||||||
|
|
||||||
|
@media (max-width: 46rem) {
|
||||||
|
.log::before { display: none; }
|
||||||
|
.entry { grid-template-columns: 1fr; gap: 0.6rem; }
|
||||||
|
.entry .rail { display: flex; gap: 0.75rem; text-align: left; }
|
||||||
|
.entry .rail::after { display: none; }
|
||||||
|
.entry .date, .entry .year, .entry .num { display: inline; }
|
||||||
|
.entry .num { margin: 0; }
|
||||||
|
.year { grid-template-columns: 1fr; gap: 0.5rem; }
|
||||||
|
.field .val { text-align: left; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<div class="mark">Log<span>b</span>uch</div>
|
||||||
|
<button class="btn" id="theme" aria-label="Farbmodus wechseln">Hell / Dunkel</button>
|
||||||
|
<button class="btn btn--primary">Eintrag schreiben</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<section class="masthead">
|
||||||
|
<h1>Was zuletzt<br>passiert ist.</h1>
|
||||||
|
<p>Alle zwei Wochen halten wir pro Projekt fest, was fertig geworden ist. Mit Bildern, in ganzen Sätzen, ohne Ticketnummern-Kauderwelsch.</p>
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field"><span class="label">Flotte</span><span class="leader"></span><span class="val">NYO · Pocket Rocket · Tajo · Aliens Exist</span></div>
|
||||||
|
<div class="field"><span class="label">Zeitraum</span><span class="leader"></span><span class="val">01.01.2026 - 30.07.2026</span></div>
|
||||||
|
<div class="field"><span class="label">Einträge</span><span class="leader"></span><span class="val">142 gesamt, 6 in den letzten 14 Tagen</span></div>
|
||||||
|
<div class="field"><span class="label">Stand</span><span class="leader"></span><span class="val">30.07.2026, 09:14</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button class="chip" aria-pressed="true">Alle Projekte</button>
|
||||||
|
<button class="chip" aria-pressed="false" style="--dot:#2e7d5b"><i></i>Trakk</button>
|
||||||
|
<button class="chip" aria-pressed="false" style="--dot:#2b4a9b"><i></i>MTA360</button>
|
||||||
|
<button class="chip" aria-pressed="false" style="--dot:#b4761a"><i></i>MTA Telematik</button>
|
||||||
|
<button class="chip" aria-pressed="false" style="--dot:#6b4ba8"><i></i>Orbit</button>
|
||||||
|
<button class="chip" aria-pressed="false" style="--dot:#a33b6a"><i></i>Pulsar</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="log">
|
||||||
|
|
||||||
|
<article class="entry entry--lead entry--unread" style="--i:0">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">30.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0142</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head">
|
||||||
|
<span class="badge badge--feature">Feature</span>
|
||||||
|
<span class="stamp">Neu</span>
|
||||||
|
</div>
|
||||||
|
<h2>Regel-Engine: Bedingung trifft Aktion</h2>
|
||||||
|
<p>Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken. Läuft über den Event-Bus im Worker, ohne Cronjobs.</p>
|
||||||
|
<div class="shots">
|
||||||
|
<div class="shot shot--wide shot--table"></div>
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#2e7d5b"><i></i>Trakk</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>3 Bilder</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>Paul</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="entry entry--unread" style="--i:1">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">29.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0141</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head"><span class="badge">Verbesserung</span></div>
|
||||||
|
<h2>Spalten per Rechtsklick verwalten</h2>
|
||||||
|
<p>Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen. Wer das nicht mag, schaltet es in den Einstellungen ab.</p>
|
||||||
|
<div class="shots">
|
||||||
|
<div class="shot shot--table"></div>
|
||||||
|
<div class="shot shot--chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#2b4a9b"><i></i>MTA360</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>2 Bilder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="entry entry--unread" style="--i:2">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">28.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0140</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head"><span class="badge badge--feature">Feature</span></div>
|
||||||
|
<h2>10.000 Tracker auf einer Karte</h2>
|
||||||
|
<p>Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft. Die Karte bleibt bei voller Flotte flüssig.</p>
|
||||||
|
<div class="shots">
|
||||||
|
<div class="shot shot--map"></div>
|
||||||
|
<div class="shot shot--chart"></div>
|
||||||
|
<div class="shot shot--table"></div>
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#b4761a"><i></i>MTA Telematik</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>3 Bilder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="entry" style="--i:3">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">25.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0139</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head"><span class="badge">Fix</span></div>
|
||||||
|
<h2>SLA-Uhr zählt Feiertage nicht mehr mit</h2>
|
||||||
|
<p>Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus. Der Kalender pro Kunde gilt jetzt auch für die Uhr.</p>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#2e7d5b"><i></i>Trakk</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>gelesen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="entry" style="--i:4">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">23.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0138</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head"><span class="badge badge--breaking">Breaking</span></div>
|
||||||
|
<h2>Lokale Tabellen-Konfigurationen werden nicht mehr gelesen</h2>
|
||||||
|
<p>Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, sieht die Standardansicht und legt seine Ansicht einmal neu an.</p>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#2b4a9b"><i></i>MTA360</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>gelesen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="entry" style="--i:5">
|
||||||
|
<div class="rail">
|
||||||
|
<span class="date">21.07.</span>
|
||||||
|
<span class="year">2026</span>
|
||||||
|
<span class="num">0137</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="head"><span class="badge">Info</span></div>
|
||||||
|
<h2>Ein Eingang für alle Kommentare</h2>
|
||||||
|
<p>Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.</p>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="proj" style="--dot:#6b4ba8"><i></i>Orbit</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>gelesen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="archive">
|
||||||
|
<h2 class="section-label">Archiv</h2>
|
||||||
|
<div class="year">
|
||||||
|
<span class="label">2026</span>
|
||||||
|
<div class="months">
|
||||||
|
<button class="month">Jan <b>14</b></button>
|
||||||
|
<button class="month">Feb <b>11</b></button>
|
||||||
|
<button class="month">Mär <b>18</b></button>
|
||||||
|
<button class="month">Apr <b>21</b></button>
|
||||||
|
<button class="month">Mai <b>19</b></button>
|
||||||
|
<button class="month">Jun <b>23</b></button>
|
||||||
|
<button class="month">Jul <b>16</b></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="year">
|
||||||
|
<span class="label">2025</span>
|
||||||
|
<div class="months">
|
||||||
|
<button class="month">Okt <b>6</b></button>
|
||||||
|
<button class="month">Nov <b>9</b></button>
|
||||||
|
<button class="month">Dez <b>5</b></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="inapp">
|
||||||
|
<h2 class="section-label">In der Anwendung</h2>
|
||||||
|
<p class="hint">Dieselben Einträge holt sich jede App über die API und zeigt sie im eigenen Design. Gelesen wird pro Nutzer der jeweiligen App gemerkt.</p>
|
||||||
|
<div class="panel">
|
||||||
|
<header>
|
||||||
|
Was ist neu in Trakk
|
||||||
|
<span class="count">3</span>
|
||||||
|
<span class="close">Esc</span>
|
||||||
|
</header>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span class="meta">30.07. · Feature</span>
|
||||||
|
<strong>Regel-Engine: Bedingung trifft Aktion</strong>
|
||||||
|
<p>Tickets reagieren jetzt selbst, ohne Cronjobs.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="meta">25.07. · Fix</span>
|
||||||
|
<strong>SLA-Uhr zählt Feiertage nicht mehr mit</strong>
|
||||||
|
<p>Keine falschen Eskalationen über Wochenenden.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="meta">18.07. · Verbesserung</span>
|
||||||
|
<strong>Anhänge direkt im Ticket</strong>
|
||||||
|
<p>Dateien landen per Drag & Drop im Verlauf.</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<footer>
|
||||||
|
<button class="btn btn--primary">Alles gelesen</button>
|
||||||
|
<button class="btn">Im Logbuch öffnen</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="site">
|
||||||
|
<span class="mark">Logbuch</span>
|
||||||
|
<span>Spaceport Adventures</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>Entwurf 30.07.2026</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const root = document.documentElement
|
||||||
|
document.getElementById('theme').addEventListener('click', () => {
|
||||||
|
const dark = getComputedStyle(root).getPropertyValue('--paper').trim() === '#15171a'
|
||||||
|
root.dataset.theme = dark ? 'light' : 'dark'
|
||||||
|
})
|
||||||
|
document.querySelectorAll('.chip').forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.chip').forEach(c => c.setAttribute('aria-pressed', 'false'))
|
||||||
|
chip.setAttribute('aria-pressed', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Logbuch Umsetzungsstand
|
||||||
|
|
||||||
|
Stand: 30.07.2026
|
||||||
|
|
||||||
|
## Plan 1, Fundament: fertig, lokal, nicht in git
|
||||||
|
|
||||||
|
| Nachweis | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| `pnpm test` | 11 Dateien, 112 Tests grün |
|
||||||
|
| `pnpm typecheck` | ohne Fehler |
|
||||||
|
| `pnpm build` | erfolgreich, 7 Routen |
|
||||||
|
| Live gegen `localhost:4700` | Lese-API, gelesen-Status und Sichtbarkeit von Hand geprüft |
|
||||||
|
| Mutationsprobe an 6 Wächtern | alle 6 werden von Tests gefangen |
|
||||||
|
|
||||||
|
Es gibt weiterhin kein Repository. Kein `git init`, kein Commit, bis der Auftraggeber die laufende Anwendung lokal abgenommen hat.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan, unterwegs entstanden
|
||||||
|
|
||||||
|
| Punkt | Was war | Was gilt |
|
||||||
|
|---|---|---|
|
||||||
|
| Postgres-Image | Volume an `/var/lib/postgresql/data` | Postgres 18 verlangt den Mount an `/var/lib/postgresql`, sonst startet der Container nicht |
|
||||||
|
| TypeScript | 7.0.2 installiert | Next 16.2.12 kommt mit der Compiler-API von TS 7 nicht klar und braucht `experimental.useTypeScriptCli`. Rückweg wäre TypeScript 6 |
|
||||||
|
| sharp | im Plan nicht erwähnt | Lässt sich auf dem Rechner nicht bauen, aus `onlyBuiltDependencies` entfernt. Wird erst für Bildvarianten in Plan 5 gebraucht und muss dort geklärt werden |
|
||||||
|
| `tsconfig.json` | im Plan mit Tabs | Next schreibt die Datei beim Start selbst um, sie ist werkzeuggesteuert |
|
||||||
|
| `findPost` | nur Slug und Zielgruppe | zusätzlich optionale Projektbindung, weil Slugs nur pro Projekt eindeutig sind |
|
||||||
|
| Token-Abfrage | SQL lag in `src/lib/api-auth.ts` | verschoben nach `src/data/repositories/clients.ts`, damit die eigene Regel gilt: kein SQL außerhalb von `src/data` |
|
||||||
|
|
||||||
|
## Behobene Mängel aus dem Review
|
||||||
|
|
||||||
|
Fünf Prüfer mit getrennten Blickwinkeln haben 34 Funde gemeldet, 14 wurden gegnerisch geprüft, 9 bestätigt, 5 widerlegt. Vier echte Ursachen:
|
||||||
|
|
||||||
|
1. **`markRead` nahm fremde Beitrags-Kennungen ungeprüft an.** Ein an Trakk gebundener Client konnte einen MTA360-Beitrag als gelesen melden, die Zeile landete mit falschem Projekt in der Tabelle und senkte den Zähler der fremden App. Die Zeilen werden jetzt aus der Datenbank abgeleitet, eingeschränkt auf Projekt, Status `published` und die für den Client sichtbaren Zielgruppen.
|
||||||
|
2. **Unbekannte Beitrags-Kennung führte zu 500.** Der Fremdschlüssel schlug durch. Das war zusätzlich ein Auskunftskanal, weil existierende Kennungen 200 und unbekannte 500 ergaben. Jetzt antwortet der Endpunkt 200 mit `marked: 0` und verrät nichts.
|
||||||
|
3. **Seitenaufteilung ohne eindeutige Sortierung.** Bei gleichem `publishAt` war die Reihenfolge zufällig, Beiträge konnten auf zwei Seiten doppelt oder gar nicht erscheinen. Sortiert wird jetzt nach `publish_at desc nulls last`, dann nach `id`.
|
||||||
|
4. **Stillgelegte Projekte lieferten weiter Beiträge aus,** obwohl `GET /api/v1/projects` sie ausblendet. `listPosts`, `findPost` und `listUnread` prüfen jetzt `project.is_active`.
|
||||||
|
|
||||||
|
Dazu Testlücken geschlossen: Projektbindung des Clients, vollständige Übergangsmatrix mit allen 25 Paaren, Zielgruppe bis zur HTTP-Antwort inklusive `scope public`, Form des Problem-JSON, Parametergrenzen, `since` als Gleichheitsgrenze, Schlagwort-Filter mit zwei Schlagworten ohne Doppelzählung, Seed-Idempotenz ohne Dubletten, 422 für Clients ohne Projektbindung.
|
||||||
|
|
||||||
|
## Bewusst nicht behoben
|
||||||
|
|
||||||
|
| Fund | Warum nicht |
|
||||||
|
|---|---|
|
||||||
|
| Beitrags-Detail liefert keine Blöcke und Medien | Plan 1 liefert genau die Listenfelder. Blöcke kommen mit dem Web-Archiv in Plan 3 |
|
||||||
|
| Schema erlaubt einen Write-Client mit `can_publish` | Es gibt noch keine Schreib-API. Die Sperre gehört in Plan 5, dort mit Test |
|
||||||
|
| Lese-Endpunkte prüfen den Client-Modus nicht | Ein Write-Client darf lesen. Die Zielgruppe schützt weiterhin, der Modus ist keine zweite Schranke |
|
||||||
|
| Kein Index deckt die Hauptabfrage der Liste | Bei dieser Datenmenge sinnlos. Gehört gemessen, nicht geraten |
|
||||||
|
| `listUnread` lädt alle ungelesenen Zeilen zum Zählen | Zählt Beiträge eines Projekts, das bleibt klein. Bei Bedarf später eine Zählabfrage |
|
||||||
|
| Seed-Token steht im Klartext in `scripts/seed.ts` | Reines Entwicklungs-Token für die lokale Datenbank, `lb_seed_trakk_read`. Darf niemals in einer erreichbaren Umgebung gelten |
|
||||||
|
| Zusammengesetzter Fremdschlüssel auf `post_read` | Die Anwendung verhindert falsche Zuordnungen jetzt zuverlässig, geprüft per Mutationsprobe. Ein zusätzlicher Datenbank-Zwang wäre Tiefenverteidigung und kostet eine Migration plus zusätzlichen Index |
|
||||||
|
|
||||||
|
## Lokale Abnahme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Volumes/M2mini/WORK/NYO/projects/logbuch
|
||||||
|
docker compose up -d
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/posts"
|
||||||
|
curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/unread?external_user_id=u-1"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:4700/api/v1/posts"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: zwei Trakk-Beiträge, kein interner Beitrag, ohne Token 401.
|
||||||
|
|
||||||
|
Das Design des Web-Archivs liegt als Entwurf unter `docs/design/mockup-overview.html` und ist im Browser direkt öffenbar.
|
||||||
|
|
||||||
|
## Nächster Schritt
|
||||||
|
|
||||||
|
Plan 2: Auth, Admin, Block-Editor, Freigabe. Offen davor: Subdomain, und ob der Admin-Login mit better-auth per Mail und Passwort startet oder auf Portal-SSO wartet.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Redaktionsleitfaden Logbuch
|
||||||
|
|
||||||
|
Diese Datei wird über `GET /api/v1/style-guide` ausgeliefert. Wer über die API schreibt, liest sie zuerst.
|
||||||
|
|
||||||
|
## Wofür Logbuch da ist
|
||||||
|
|
||||||
|
Logbuch hält fest, was in den Produkten der Firmengruppe passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag über das, was fertig geworden ist.
|
||||||
|
|
||||||
|
Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung. Es ist für Kollegen und später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen.
|
||||||
|
|
||||||
|
## Wer liest
|
||||||
|
|
||||||
|
Technische und nicht technische Kollegen aus allen Bereichen. Später Kunden der jeweiligen Produkte. Sie lesen nebenbei, meist weil ihre Anwendung sie auf etwas Neues hingewiesen hat.
|
||||||
|
|
||||||
|
Daraus folgt: kein Fachjargon ohne Erklärung, keine Ticketnummern, keine Klassennamen, keine Abkürzungen, die nur im Team bekannt sind.
|
||||||
|
|
||||||
|
## Aufbau eines Eintrags
|
||||||
|
|
||||||
|
1. **Titel.** Sagt, was sich geändert hat. Nicht, dass sich etwas geändert hat.
|
||||||
|
2. **Anreißer.** Zwei bis drei Sätze, die den Kern nennen und was der Leser davon hat.
|
||||||
|
3. **Text.** Was war vorher, was ist jetzt, was bleibt zu tun. Meist drei bis sechs Absätze.
|
||||||
|
|
||||||
|
Eine Fehlerbehebung darf aus zwei Sätzen bestehen. Eine neue Funktion braucht mehr, weil sie erklärt werden muss.
|
||||||
|
|
||||||
|
## Titel
|
||||||
|
|
||||||
|
Gut:
|
||||||
|
|
||||||
|
- "SLA-Uhr zählt Feiertage nicht mehr mit"
|
||||||
|
- "Spalten per Rechtsklick verwalten"
|
||||||
|
- "10.000 Tracker auf einer Karte"
|
||||||
|
|
||||||
|
Schlecht:
|
||||||
|
|
||||||
|
- "Verbesserungen an der Tabelle" (sagt nichts)
|
||||||
|
- "Wir haben ein spannendes neues Feature gebaut" (Werbung statt Information)
|
||||||
|
- "MTT-1423 umgesetzt" (Ticketnummer, für Leser bedeutungslos)
|
||||||
|
|
||||||
|
## Ton
|
||||||
|
|
||||||
|
Sachlich und direkt. Aktive Verben. Der Leser wird geduzt oder gar nicht angesprochen, beides ist in Ordnung, aber nicht gemischt.
|
||||||
|
|
||||||
|
Verboten: Werbesprache, Superlative, Ausrufezeichen, Emojis, Gedankenstriche. Immer normales Minus. Echte Umlaute, niemals ae, oe, ue.
|
||||||
|
|
||||||
|
Nicht behaupten, dass etwas großartig ist. Beschreiben, was es tut, und den Leser selbst urteilen lassen.
|
||||||
|
|
||||||
|
## Bilder
|
||||||
|
|
||||||
|
Zeigen, was sich geändert hat. Kein Firmenlogo, keine Symbolbilder, keine Menschen mit Laptop.
|
||||||
|
|
||||||
|
Jedes Bild bekommt einen Alt-Text, der beschreibt, was zu sehen ist. Bei öffentlichen Beiträgen ist der Alt-Text Pflicht, sonst lässt sich der Beitrag nicht veröffentlichen.
|
||||||
|
|
||||||
|
## Zielgruppe wählen
|
||||||
|
|
||||||
|
| Zielgruppe | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| `internal` | Umbauten, Zwischenstände, alles über Kunden, alles Unfertige |
|
||||||
|
| `customer` | Was ein Kunde des Produkts wissen soll |
|
||||||
|
| `public` | Was auch außerhalb der Firma stehen darf |
|
||||||
|
|
||||||
|
Im Zweifel `internal`. Hochstufen kann ein Mensch beim Freigeben, herunterstufen ist zu spät, wenn es schon draußen war.
|
||||||
|
|
||||||
|
## Beitragsarten
|
||||||
|
|
||||||
|
Die Arten werden im Adminbereich verwaltet und sind über `GET /api/v1/post-types` abrufbar. Standardmäßig gibt es:
|
||||||
|
|
||||||
|
| Schlüssel | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| `feature` | Etwas Neues, das es vorher nicht gab |
|
||||||
|
| `improvement` | Etwas Bestehendes wurde besser |
|
||||||
|
| `fix` | Ein Fehler ist behoben |
|
||||||
|
| `breaking` | Eine Änderung, die Nutzer zum Handeln zwingt |
|
||||||
|
| `info` | Hinweis ohne Produktänderung |
|
||||||
|
|
||||||
|
## Wer veröffentlicht
|
||||||
|
|
||||||
|
Nicht der API-Zugang. Ein über die API verfasster Beitrag ist immer ein Entwurf und damit ein Vorschlag. Ein Mensch liest ihn und gibt frei.
|
||||||
|
|
||||||
|
Deshalb: lieber einen Entwurf zu viel als einen fehlenden. Aber keine halben Sachen einreichen, die jemand anderes zu Ende schreiben muss.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
|||||||
|
# Logbuch - Design
|
||||||
|
|
||||||
|
Stand: 30.07.2026
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Logbuch ist die zentrale Stelle, an der pro Projekt festgehalten wird, was passiert ist. Alle zwei Wochen picken sich Moderatoren die stärksten Neuerungen eines Projekts heraus, schreiben sie mit Screenshots auf und veröffentlichen sie. Die jeweilige Anwendung holt die Beiträge über eine API und zeigt sie ihren Nutzern als "Was ist neu" an. Zusätzlich gibt es ein Web-Archiv in Blog-Form: alle Projekte auf einer Übersicht, oder ein einzelnes Projekt mit Jahres- und Monatsarchiv.
|
||||||
|
|
||||||
|
Der Aufbau ist durchgängig dynamisch. Marken, Projekte, Beitragstypen, Inhaltsblöcke und Kanäle sind Daten, nicht Code. Ein neues Projekt anzulegen ist eine Eingabe im Admin, kein Deployment.
|
||||||
|
|
||||||
|
## Rahmenbedingungen und Entscheidungen
|
||||||
|
|
||||||
|
| Punkt | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Reichweite | Zuerst intern, Datenmodell aber mit Marke, Projekt und Zielgruppe, damit Kundennutzung ohne Umbau möglich ist |
|
||||||
|
| Inhalte | Von Hand im Admin aus Blöcken gebaut, dazu Import-Vorschläge aus Trakk und GitHub als Rohmaterial |
|
||||||
|
| Kanäle Runde eins | Web-Archiv und In-App über API |
|
||||||
|
| Kanäle später | Mail über SES, Push über ntfy. Im Datenmodell vorgesehen, nicht gebaut |
|
||||||
|
| Deployment | Eigene App, eigenes Repo, eigene Subdomain, Coolify. Nicht Teil von Portal-v2 |
|
||||||
|
| Auth | better-auth für Moderatoren und Admins, Portal-SSO später. Basic-Auth-Gate vor der Instanz wie bei allen NYO-Apps |
|
||||||
|
| Medien | Coolify-Volume hinter einer Storage-Abstraktion, S3 später als Adapter |
|
||||||
|
| Web-Archiv Runde eins | Interne Ansicht hinter Basic-Auth. Kundensicht läuft ausschließlich über die App-API |
|
||||||
|
| Lesen | Bewusst offen. Wer durch das Basic-Auth-Gate kommt, darf alle Projekte und alle internen Beiträge lesen. Kein Leser-Konto, keine Leserechte-Pflege |
|
||||||
|
| Erfassen | Muss in unter zwei Minuten gehen, ohne Einarbeitung. Wenn Kollegen es als Aufwand empfinden, wird es nicht benutzt. Siehe Abschnitt "Erfassen ohne Reibung" |
|
||||||
|
| Erscheinungsbild | Wie ein guter Blog, nicht wie ein Verwaltungswerkzeug. Hell ist Standard, Dunkel vollständig unterstützt |
|
||||||
|
|
||||||
|
### Ausdrücklich nicht im Umfang
|
||||||
|
|
||||||
|
Mailversand, eigene Absenderdomains pro Kunde, Kampagnen-Automationen, A/B-Tests, Billing, Kommentarfunktion, öffentliche Anmeldeformulare, Double-Opt-in.
|
||||||
|
|
||||||
|
## Firmen- und Projektstruktur
|
||||||
|
|
||||||
|
Spaceport Adventures ist das Dach, darunter die Schwestern NYO, Pocket Rocket, Tajo und Aliens Exist. Marke ist deshalb eine eigene Ebene über dem Projekt, nicht ein Feld am Projekt. Die Startseite kann nach Marke gruppieren, ein Projekt gehört immer zu genau einer Marke.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
Übernommen aus Trakk und Portal-v2, damit der Kosmos einheitlich bleibt:
|
||||||
|
|
||||||
|
- Next.js 16.2 (App Router), React 19
|
||||||
|
- PostgreSQL mit Drizzle ORM
|
||||||
|
- Tailwind 4
|
||||||
|
- better-auth für Authentifizierung
|
||||||
|
- next-intl für Zweisprachigkeit (de, en)
|
||||||
|
- zod für Validierung an allen API-Grenzen
|
||||||
|
- react-icons mit Tabler-Icons
|
||||||
|
- overlayscrollbars statt native Scrollbars
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
### Schichten
|
||||||
|
|
||||||
|
Vier klar getrennte Einheiten, jede mit eigener Aufgabe und eigener Schnittstelle:
|
||||||
|
|
||||||
|
1. **Domain** (`src/domain/`): Kernlogik ohne Framework-Bezug. Sichtbarkeitsregeln, Statusübergänge, Rechteprüfung, Slug-Erzeugung. Reine Funktionen, vollständig testbar ohne Datenbank.
|
||||||
|
2. **Data** (`src/data/`): Drizzle-Schema und Repositories. Jede Tabelle bekommt ein Repository mit benannten Abfragen. Kein SQL außerhalb dieser Schicht.
|
||||||
|
3. **API** (`src/app/api/v1/`): HTTP-Grenze. Nimmt Anfragen an, validiert per zod, prüft Token oder Session, ruft Domain und Data, gibt JSON zurück. Keine Logik.
|
||||||
|
4. **UI** (`src/app/(admin)/`, `src/app/(public)/`): Admin und Web-Archiv. Holt Daten über Server Components, schreibt über Server Actions oder die API.
|
||||||
|
|
||||||
|
Die Sichtbarkeitsregel lebt ausschließlich in der Domain-Schicht und wird an genau einer Stelle angewendet, bevor Daten die API verlassen. Kein Frontend filtert Zielgruppen.
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### brand
|
||||||
|
|
||||||
|
Marke oder Firma. `id`, `slug`, `name`, `color`, `logo_media_id`, `sort`, `created_at`.
|
||||||
|
|
||||||
|
### project
|
||||||
|
|
||||||
|
`id`, `brand_id`, `slug`, `name`, `description`, `color`, `logo_media_id`, `is_active`, `sort`, `created_at`.
|
||||||
|
|
||||||
|
Slug ist projektweit eindeutig und Teil der Web-Routen.
|
||||||
|
|
||||||
|
### post
|
||||||
|
|
||||||
|
Die zentrale Einheit.
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `id` | |
|
||||||
|
| `project_id` | Zugehöriges Projekt |
|
||||||
|
| `issue_id` | Optionale Ausgabe |
|
||||||
|
| `slug` | Eindeutig pro Projekt |
|
||||||
|
| `title`, `teaser` | |
|
||||||
|
| `type` | `feature`, `improvement`, `fix`, `breaking`, `info` |
|
||||||
|
| `audience` | `internal`, `customer`, `public` |
|
||||||
|
| `status` | `draft`, `review`, `scheduled`, `published`, `archived` |
|
||||||
|
| `locale` | `de` oder `en` |
|
||||||
|
| `translation_group` | Verbindet Übersetzungen desselben Beitrags. Beiträge können auch nur einsprachig existieren |
|
||||||
|
| `publish_at` | Termin bei `scheduled`, Veröffentlichungszeitpunkt bei `published` |
|
||||||
|
| `author_id` | Autor, auch ein API-Client möglich |
|
||||||
|
| `created_by_client_id` | Gesetzt, wenn der Beitrag über die API entstand |
|
||||||
|
| `cover_media_id` | Aufmacherbild |
|
||||||
|
| `search_vector` | tsvector für Volltextsuche |
|
||||||
|
| `created_at`, `updated_at` | |
|
||||||
|
|
||||||
|
Die Zielgruppen sind aufsteigend offen: `public` sehen alle, `customer` sehen Kunden und intern, `internal` nur intern. Ein Kunde bekommt nie einen internen Beitrag ausgeliefert, auch nicht als Titel in einer Liste.
|
||||||
|
|
||||||
|
### block
|
||||||
|
|
||||||
|
Inhaltsblöcke eines Beitrags. `id`, `post_id`, `sort`, `type`, `data` (JSONB).
|
||||||
|
|
||||||
|
Blocktypen in Runde eins: `text` (Rich Text), `image`, `gallery`, `before_after`, `video`, `quote`, `code`, `link`, `callout`.
|
||||||
|
|
||||||
|
Jeder Typ hat ein zod-Schema in einer Registry und eine Render-Komponente. Ein neuer Typ heißt: Schema plus Komponente plus Editor-Eingabe. Kein Schema-Change an der Datenbank.
|
||||||
|
|
||||||
|
### issue
|
||||||
|
|
||||||
|
Optionale Klammer über mehrere Beiträge. `id`, `project_id`, `title`, `period_from`, `period_to`, `status`, `publish_at`, `created_at`.
|
||||||
|
|
||||||
|
Eine Ausgabe erzwingt keinen Rhythmus. Beiträge können jederzeit einzeln erscheinen, oder gebündelt als Ausgabe alle zwei Wochen.
|
||||||
|
|
||||||
|
### tag, post_tag
|
||||||
|
|
||||||
|
Freie Schlagworte pro Projekt, Filter im Archiv.
|
||||||
|
|
||||||
|
### media
|
||||||
|
|
||||||
|
`id`, `project_id`, `kind` (`image`, `video`), `original_filename`, `mime`, `width`, `height`, `byte_size`, `variants` (JSONB mit Breite, Format und Pfad je Variante), `alt`, `caption`, `uploaded_by`, `created_at`.
|
||||||
|
|
||||||
|
Bilder werden beim Upload nach WebP konvertiert und in mehreren Breiten abgelegt. Alt-Text wird angemahnt, ist aber nur bei öffentlichen Beiträgen Pflicht.
|
||||||
|
|
||||||
|
### user, user_project_role
|
||||||
|
|
||||||
|
`user` kommt von better-auth, ergänzt um `is_admin`.
|
||||||
|
|
||||||
|
`user_project_role`: `user_id`, `project_id`, `role` (`moderator`).
|
||||||
|
|
||||||
|
Ein Konto braucht nur, wer schreibt. Lesen ist offen und an keine Rolle gebunden. Schreibrechte gelten pro Projekt, ein Admin hat implizit alle Rechte in allen Projekten.
|
||||||
|
|
||||||
|
Die Tabelle trägt eine Rollenspalte statt eines Kennzeichens, damit weitere Rollen später ohne Migration dazukommen können.
|
||||||
|
|
||||||
|
### api_client
|
||||||
|
|
||||||
|
`id`, `name`, `token_hash`, `project_id` (null bedeutet alle Projekte), `scopes`, `can_publish` (Standard false), `last_used_at`, `revoked_at`.
|
||||||
|
|
||||||
|
Zwei Arten von Clients: **Write-Clients** legen Drafts an und laden Medien hoch. **Read-Clients** sind die eingebundenen Anwendungen und holen veröffentlichte Beiträge ihrer Zielgruppe.
|
||||||
|
|
||||||
|
### post_read
|
||||||
|
|
||||||
|
`project_id`, `external_user_id`, `post_id`, `read_at`.
|
||||||
|
|
||||||
|
Der gelesen-Status hängt an der Nutzerkennung der Zielanwendung, nicht an einem Logbuch-Konto. Trakk schickt seine eigene User-ID mit, MTA360 seine. Die Kennung wird pro Projekt gespeichert, damit sich IDs verschiedener Anwendungen nicht überschneiden.
|
||||||
|
|
||||||
|
### reaction
|
||||||
|
|
||||||
|
`post_id`, `project_id`, `external_user_id`, `kind` (`useful`, `question`), `created_at`. Eindeutig pro Nutzer, Beitrag und Art.
|
||||||
|
|
||||||
|
In Runde eins nur aus der App, weil das Web-Archiv keine Leser-Identität hat.
|
||||||
|
|
||||||
|
### delivery
|
||||||
|
|
||||||
|
`id`, `post_id`, `channel` (`web`, `inapp`, `mail`, `push`), `status`, `dispatched_at`, `error`.
|
||||||
|
|
||||||
|
Web und In-App werden beim Veröffentlichen als erledigt vermerkt. Die Tabelle existiert, damit Mail und Push später nur einen Adapter brauchen.
|
||||||
|
|
||||||
|
### audit_log
|
||||||
|
|
||||||
|
`id`, `actor_type` (`user`, `client`), `actor_id`, `action`, `entity`, `entity_id`, `data`, `created_at`.
|
||||||
|
|
||||||
|
Protokolliert mindestens: Veröffentlichen, Zurückziehen, Zielgruppenwechsel, Rechteänderung, Token-Erzeugung.
|
||||||
|
|
||||||
|
## Rollen und Rechte
|
||||||
|
|
||||||
|
| Rolle | Darf |
|
||||||
|
|---|---|
|
||||||
|
| Admin | Alles. Marken, Projekte, Nutzer, Tokens verwalten |
|
||||||
|
| Moderator (pro Projekt) | Beiträge und Ausgaben anlegen, bearbeiten, veröffentlichen, zurückziehen, Medien hochladen |
|
||||||
|
| Jeder im internen Archiv | Alle Projekte und alle Beiträge lesen, ohne Konto und ohne Rollenzuweisung |
|
||||||
|
| Write-Client | Drafts anlegen und bearbeiten, Medien hochladen. Nicht veröffentlichen |
|
||||||
|
| Read-Client | Veröffentlichte Beiträge der erlaubten Zielgruppe abrufen, gelesen melden |
|
||||||
|
|
||||||
|
Lesen ist bewusst nicht eingeschränkt. Der Schutz liegt in Runde eins allein am Basic-Auth-Gate vor der Instanz. Das ist eine bewusste Entscheidung, damit niemand Leserechte pflegen muss, und gilt nur solange das Web-Archiv intern ist. Sobald Kunden eine Web-Ansicht bekommen sollen, muss diese Entscheidung neu getroffen werden.
|
||||||
|
|
||||||
|
`can_publish` bleibt für Write-Clients auf false. Damit landet nichts unkontrolliert bei Kunden, auch nicht aus einem automatisierten Lauf.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Version im Pfad, `/api/v1`. Alle Eingaben zod-validiert. Fehler einheitlich als Problem-JSON mit `type`, `title`, `status`, `detail`.
|
||||||
|
|
||||||
|
### Lesend, Bearer eines Read-Clients
|
||||||
|
|
||||||
|
| Route | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/brands` | Marken |
|
||||||
|
| `GET /api/v1/projects` | Projekte, gefiltert auf die Berechtigung des Clients |
|
||||||
|
| `GET /api/v1/posts` | Beiträge. Parameter: `project`, `since`, `type`, `tag`, `q`, `page`, `per_page` |
|
||||||
|
| `GET /api/v1/posts/:slug` | Einzelner Beitrag mit Blöcken und Medien |
|
||||||
|
| `GET /api/v1/issues` | Ausgaben eines Projekts |
|
||||||
|
| `GET /api/v1/unread` | Ungelesene Beiträge. Parameter: `external_user_id` |
|
||||||
|
| `POST /api/v1/read` | Meldet einen oder mehrere Beiträge als gelesen |
|
||||||
|
| `POST /api/v1/reactions` | Reaktion setzen oder entfernen |
|
||||||
|
|
||||||
|
Die Zielgruppe wird nicht vom Aufrufer bestimmt, sondern aus dem Client abgeleitet. Ein Read-Client der MTA360-Kundenoberfläche bekommt niemals interne Beiträge, egal welche Parameter er schickt.
|
||||||
|
|
||||||
|
### Schreibend, Bearer eines Write-Clients
|
||||||
|
|
||||||
|
| Route | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/posts` | Beitrag anlegen, Status immer `draft` |
|
||||||
|
| `PATCH /api/v1/posts/:id` | Beitrag ändern, solange er nicht veröffentlicht ist |
|
||||||
|
| `PUT /api/v1/posts/:id/blocks` | Blöcke setzen, vollständige Liste in Reihenfolge |
|
||||||
|
| `POST /api/v1/media` | Datei hochladen, multipart. Liefert Media-ID und Varianten |
|
||||||
|
|
||||||
|
`POST /api/v1/posts` akzeptiert einen `idempotency_key`, damit ein wiederholter Aufruf keinen Doppelbeitrag erzeugt.
|
||||||
|
|
||||||
|
### Feeds
|
||||||
|
|
||||||
|
`GET /feed/:project.xml` als Atom, nur Beiträge der Zielgruppe `public`.
|
||||||
|
|
||||||
|
## Einbindung in die Anwendungen
|
||||||
|
|
||||||
|
Keine iframes. Die Anwendungen holen JSON und rendern selbst, damit das Panel im jeweiligen Design sitzt.
|
||||||
|
|
||||||
|
Dazu zwei dünne Client-Pakete im Repo:
|
||||||
|
|
||||||
|
- `packages/client-react` für Trakk, Orbit, Arc und weitere Next-Apps
|
||||||
|
- `packages/client-vue` für MTA360 und nyo-frontend
|
||||||
|
|
||||||
|
Beide bieten dasselbe: Beiträge laden, ungelesene Anzahl als Badge, Panel mit Beitragsliste und Detailansicht, gelesen melden, reagieren. Die Anwendung übergibt Read-Key, Projekt-Slug und ihre eigene Nutzerkennung.
|
||||||
|
|
||||||
|
Der Read-Key gehört serverseitig in die einbindende App. Die Pakete rufen einen kleinen Proxy-Endpunkt der jeweiligen App auf, damit der Key nie im Browser liegt.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
- Projekt-Switcher in der Kopfzeile, Rechte bestimmen die Auswahl
|
||||||
|
- Beitragsliste mit Status, Typ, Zielgruppe, Termin, Filter und Suche
|
||||||
|
- Block-Editor: Blöcke hinzufügen, sortieren per Drag & Drop, Bilder direkt in den Editor ziehen
|
||||||
|
- Vorschau umschaltbar zwischen den Zielgruppen, damit vor dem Veröffentlichen sichtbar ist, was ein Kunde sieht
|
||||||
|
- Workflow `draft` nach `review` nach `published`, mit Zurückziehen nach `archived`
|
||||||
|
- Terminierung: Termin setzen, ein Cron veröffentlicht
|
||||||
|
- Ausgaben: Zeitraum wählen, Beiträge zuordnen, gemeinsam veröffentlichen
|
||||||
|
- Medienbibliothek pro Projekt mit Alt-Text-Pflege
|
||||||
|
- Verwaltung von Marken, Projekten, Nutzern, Rollen und Tokens für Admins
|
||||||
|
|
||||||
|
## Erfassen ohne Reibung
|
||||||
|
|
||||||
|
Das Projekt scheitert oder gelingt an dieser Stelle. Wenn ein Kollege für einen Beitrag über eine neue Funktion mehr als zwei Minuten braucht oder erst etwas lernen muss, schreibt er nichts. Deshalb sind das harte Anforderungen, keine Verbesserungen für später:
|
||||||
|
|
||||||
|
- **Ein Feld zum Anfangen.** Neuer Beitrag heißt: Titel eingeben, losschreiben. Projekt ist aus dem Kontext vorbelegt, Typ ist `feature`, Zielgruppe ist `internal`, Sprache ist `de`. Alles änderbar, nichts abzufragen
|
||||||
|
- **Screenshot aus der Zwischenablage.** Bild mit Strg+V direkt in den Editor einfügen, ohne Datei-Dialog. Genauso Drag & Drop von mehreren Bildern auf einmal, die dann als Galerie landen
|
||||||
|
- **Schreiben wie in einem Textfeld.** Der Text-Block versteht Markdown-Eingaben beim Tippen (Überschrift, Liste, Fettung, Link). Wer Markdown nicht kennt, benutzt die Werkzeugleiste. Eingefügter Text aus anderen Quellen behält Struktur, verliert Fremdformatierung
|
||||||
|
- **Automatisches Speichern.** Entwürfe speichern sich selbst. Kein verlorener Text, keine Speicher-Frage beim Verlassen
|
||||||
|
- **Blöcke sind unsichtbar, bis man sie braucht.** Wer nur Text und Bilder schreibt, sieht keine Block-Verwaltung. Weitere Blocktypen kommen über einen Einfüge-Knopf oder `/` im Text
|
||||||
|
- **Veröffentlichen ist ein Knopf.** Kein Pflicht-Review, kein Pflicht-Termin, keine Pflicht-Schlagworte. Der Review-Status existiert für die, die ihn wollen
|
||||||
|
- **Nichts blockiert ohne Grund.** Prüfungen vor dem Veröffentlichen sind Hinweise, keine Sperren. Gesperrt wird nur, was echten Schaden anrichtet: fehlende Zielgruppe und öffentliche Beiträge ohne Alt-Text
|
||||||
|
|
||||||
|
## Erscheinungsbild
|
||||||
|
|
||||||
|
Es soll aussehen wie ein guter Blog, den man freiwillig liest, nicht wie eine Verwaltungsmaske. Große Aufmacherbilder, ruhige Typografie mit ordentlicher Zeilenlänge im Lesetext, klare Abstände, Beitragstypen als dezente Badges, Projektfarbe als Akzent statt als Anstrich.
|
||||||
|
|
||||||
|
Harte Regeln:
|
||||||
|
|
||||||
|
- **Keine nativen Scrollbars.** Überall overlayscrollbars, auch in Panels, Modalen, Listen, Medienbibliothek und im In-App-Panel. Keine Ausnahme
|
||||||
|
- **Kein großes Stylesheet.** Gestaltet wird mit Tailwind-Utilities und Design-Tokens. Das globale CSS enthält nur Tokens, Basis-Typografie und das overlayscrollbars-Thema und bleibt unter 150 Zeilen. Keine CSS-Datei pro Komponente, kein wachsendes Sammel-Stylesheet
|
||||||
|
- **Hell ist Standard, Dunkel ist vollwertig.** Umsetzung über CSS-Variablen als Tokens plus Tailwind-Dark-Variante. Beide Modi über dieselben Tokens, kein zweites Stylesheet, keine Sonderfälle je Komponente. Umschalter im Kopfbereich, Auswahl wird gespeichert, Systemeinstellung als dritte Option
|
||||||
|
- **Keine Emojis** in Oberfläche und Inhalten
|
||||||
|
- **Keine Pixelangaben**, Abstände und Größen über die Tailwind-Skala
|
||||||
|
- Icons ausschließlich Tabler über react-icons
|
||||||
|
|
||||||
|
### Import-Vorschläge
|
||||||
|
|
||||||
|
Ein Panel im Beitrags-Editor zieht Rohmaterial für einen Zeitraum:
|
||||||
|
|
||||||
|
- Erledigte Tickets aus der Trakk-API des zugeordneten Projekts
|
||||||
|
- Releases und zusammengeführte Pull Requests aus GitHub
|
||||||
|
|
||||||
|
Ergebnis ist eine Auswahlliste. Angehakte Einträge werden als Blöcke mit Titel und Link eingefügt. Kein generierter Text. Die Zuordnung Projekt zu Trakk-Projekt und GitHub-Repository steht am Projekt.
|
||||||
|
|
||||||
|
## Web-Archiv
|
||||||
|
|
||||||
|
| Route | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Übersicht aller Projekte, gruppiert nach Marke, neueste Beiträge |
|
||||||
|
| `/[project]` | Projekt-Blog mit Seitenweiterschaltung |
|
||||||
|
| `/[project]/[year]` und `/[project]/[year]/[month]` | Zeitarchiv |
|
||||||
|
| `/[project]/[slug]` | Einzelner Beitrag |
|
||||||
|
| `/[project]/tag/[tag]` | Schlagwort-Filter |
|
||||||
|
| `/issues/[id]` | Ausgabe als Ganzes |
|
||||||
|
| `/search` | Volltextsuche über Postgres tsvector |
|
||||||
|
|
||||||
|
Zweisprachig über next-intl, keine festverdrahteten Texte. Beitragstypen als Badges. OG-Bilder werden aus Projektfarbe, Logo und Titel erzeugt. Bilder als WebP mit `srcset`, Galerie mit Lightbox.
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- Eingaben scheitern laut und früh: zod an jeder API-Grenze, Antwort mit Feldfehlern
|
||||||
|
- Veröffentlichen prüft vorab und unterscheidet Hinweis von Sperre. Gesperrt wird nur bei fehlender Zielgruppe, fehlendem Titel, leerem Beitrag oder öffentlichem Beitrag ohne Alt-Text. Fehlender Alt-Text bei internen Beiträgen, fehlende Schlagworte oder fehlendes Aufmacherbild sind Hinweise und halten niemanden auf
|
||||||
|
- Medien-Upload ist zweistufig: Datei speichern, dann Varianten erzeugen. Scheitert die Konvertierung, bleibt das Original nutzbar und der Fehler steht am Medium
|
||||||
|
- Der Veröffentlichungs-Cron ist wiederholbar: er nimmt Beiträge mit `status = scheduled` und `publish_at <= jetzt`, ein Doppellauf erzeugt keine Doppelauslieferung, weil `delivery` den Zustand hält
|
||||||
|
- Ausfälle von Trakk oder GitHub beim Import blockieren den Editor nicht, das Panel zeigt den Fehler und bleibt schließbar
|
||||||
|
- Unbekannte Blocktypen im Renderer werden übersprungen und protokolliert, statt die Seite zu zerlegen
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Der Schwerpunkt liegt dort, wo ein Fehler weh tut.
|
||||||
|
|
||||||
|
| Bereich | Art |
|
||||||
|
|---|---|
|
||||||
|
| Sichtbarkeitsregel (Zielgruppe je Read-Client) | Vitest, vollständige Matrix |
|
||||||
|
| Statusübergänge und Veröffentlichungs-Prüfungen | Vitest |
|
||||||
|
| Schreibrechte pro Projekt, Write-Client darf nicht veröffentlichen | Vitest |
|
||||||
|
| Block-Schemas | Vitest gegen die Registry |
|
||||||
|
| API-Routen | Vitest mit Testdatenbank, Schwerpunkt Auth und Filter |
|
||||||
|
| Draft bis Veröffentlicht bis In-App-Abruf | Ein Playwright-Smoketest |
|
||||||
|
|
||||||
|
Die Sichtbarkeitsmatrix bekommt bewusst mehr Tests als der Rest, weil ein Fehler dort interne Inhalte an Kunden ausliefert.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Eigenes Repository, Verzeichnis `projects/logbuch`
|
||||||
|
- Coolify-Deployment, eigene Subdomain, Basic-Auth-Gate davor
|
||||||
|
- Eigene PostgreSQL-Instanz
|
||||||
|
- Migrationen über Drizzle, beim Deployment ausgeführt
|
||||||
|
- Medien auf einem Coolify-Volume, Zugriff über eine Storage-Schnittstelle mit einem lokalen Adapter. Ein S3-Adapter kann später ohne Änderung am Rest ergänzt werden
|
||||||
|
- Cron für terminierte Veröffentlichung
|
||||||
|
- Rybbit für Zugriffszahlen des Web-Archivs
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
- Subdomain noch nicht festgelegt
|
||||||
|
- Welche Nutzerkennung MTA360 und Trakk für den gelesen-Status liefern, muss beim Einbinden je App geklärt werden
|
||||||
|
- Ob Tajo und Aliens Exist eigene Projekte in Logbuch bekommen, ist offen. Das Datenmodell trägt sie
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/data/schema/index.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "brand" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"color" text DEFAULT '#191c20' NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "brand_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "project" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"brand_id" uuid NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"color" text DEFAULT '#191c20' NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "project_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "project" ADD CONSTRAINT "project_brand_id_brand_id_fk" FOREIGN KEY ("brand_id") REFERENCES "public"."brand"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
CREATE TYPE "public"."media_kind" AS ENUM('image', 'video');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."issue_status" AS ENUM('draft', 'scheduled', 'published');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."post_audience" AS ENUM('internal', 'customer', 'public');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."post_locale" AS ENUM('de', 'en');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."post_status" AS ENUM('draft', 'review', 'scheduled', 'published', 'archived');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."post_type" AS ENUM('feature', 'improvement', 'fix', 'breaking', 'info');--> statement-breakpoint
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"kind" "media_kind" DEFAULT 'image' NOT NULL,
|
||||||
|
"original_filename" text NOT NULL,
|
||||||
|
"mime" text NOT NULL,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"byte_size" integer NOT NULL,
|
||||||
|
"variants" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"alt" text,
|
||||||
|
"caption" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "block" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"post_id" uuid NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"data" jsonb DEFAULT '{}'::jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "issue" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"period_from" timestamp with time zone,
|
||||||
|
"period_to" timestamp with time zone,
|
||||||
|
"status" "issue_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"publish_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "post_tag" (
|
||||||
|
"post_id" uuid NOT NULL,
|
||||||
|
"tag_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "post_tag_post_id_tag_id_pk" PRIMARY KEY("post_id","tag_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "post" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid,
|
||||||
|
"number" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"teaser" text,
|
||||||
|
"type" "post_type" DEFAULT 'feature' NOT NULL,
|
||||||
|
"audience" "post_audience" DEFAULT 'internal' NOT NULL,
|
||||||
|
"status" "post_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"locale" "post_locale" DEFAULT 'de' NOT NULL,
|
||||||
|
"translation_group" uuid,
|
||||||
|
"publish_at" timestamp with time zone,
|
||||||
|
"author_name" text,
|
||||||
|
"cover_media_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "post_project_slug" UNIQUE("project_id","slug"),
|
||||||
|
CONSTRAINT "post_project_number" UNIQUE("project_id","number")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tag" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
CONSTRAINT "tag_project_slug" UNIQUE("project_id","slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "media" ADD CONSTRAINT "media_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "block" ADD CONSTRAINT "block_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue" ADD CONSTRAINT "issue_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post_tag" ADD CONSTRAINT "post_tag_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post_tag" ADD CONSTRAINT "post_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post" ADD CONSTRAINT "post_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post" ADD CONSTRAINT "post_issue_id_issue_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issue"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post" ADD CONSTRAINT "post_cover_media_id_media_id_fk" FOREIGN KEY ("cover_media_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tag" ADD CONSTRAINT "tag_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "block_post_sort" ON "block" USING btree ("post_id","sort");--> statement-breakpoint
|
||||||
|
CREATE INDEX "post_publish_at" ON "post" USING btree ("publish_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "post_project_status" ON "post" USING btree ("project_id","status");
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TYPE "public"."client_mode" AS ENUM('read', 'write');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."client_scope" AS ENUM('internal', 'customer', 'public');--> statement-breakpoint
|
||||||
|
CREATE TABLE "api_client" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"mode" "client_mode" DEFAULT 'read' NOT NULL,
|
||||||
|
"scope" "client_scope" DEFAULT 'customer' NOT NULL,
|
||||||
|
"project_id" uuid,
|
||||||
|
"can_publish" boolean DEFAULT false NOT NULL,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "api_client_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "post_read" (
|
||||||
|
"post_id" uuid NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"external_user_id" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "post_read_post_id_external_user_id_pk" PRIMARY KEY("post_id","external_user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_client" ADD CONSTRAINT "api_client_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post_read" ADD CONSTRAINT "post_read_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "post_read" ADD CONSTRAINT "post_read_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "post_read_project_user" ON "post_read" USING btree ("project_id","external_user_id");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "project" ADD COLUMN "code" text;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
CREATE TYPE "public"."project_role" AS ENUM('moderator');--> statement-breakpoint
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token_expires_at" timestamp with time zone,
|
||||||
|
"refresh_token_expires_at" timestamp with time zone,
|
||||||
|
"scope" text,
|
||||||
|
"id_token" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_project_role" (
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"role" "project_role" DEFAULT 'moderator' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_project_role_user_id_project_id_pk" PRIMARY KEY("user_id","project_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"is_admin" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_project_role" ADD CONSTRAINT "user_project_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_project_role" ADD CONSTRAINT "user_project_role_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_user" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_user" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_project_role_project" ON "user_project_role" USING btree ("project_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_identifier" ON "verification" USING btree ("identifier");
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TYPE "public"."audit_actor_type" AS ENUM('user', 'client');--> statement-breakpoint
|
||||||
|
CREATE TABLE "audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"actor_type" "audit_actor_type" DEFAULT 'user' NOT NULL,
|
||||||
|
"actor_id" text,
|
||||||
|
"actor_label" text,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"entity" text NOT NULL,
|
||||||
|
"entity_id" text,
|
||||||
|
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_created" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_entity" ON "audit_log" USING btree ("entity","entity_id");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "media" ADD COLUMN "path" text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "media" ADD COLUMN "variant_error" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "media" ADD COLUMN "uploaded_by" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "media" ADD CONSTRAINT "media_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "media_project_created" ON "media" USING btree ("project_id","created_at");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "post_counter" (
|
||||||
|
"project_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"value" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "post_counter" ADD CONSTRAINT "post_counter_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
ALTER TYPE "public"."post_type" RENAME TO "post_type_legacy";--> statement-breakpoint
|
||||||
|
CREATE TABLE "post_type" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"label_de" text NOT NULL,
|
||||||
|
"label_en" text NOT NULL,
|
||||||
|
"color" text DEFAULT '#191c20' NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "post_type_key_unique" UNIQUE("key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "post_type" ("key", "label_de", "label_en", "color", "sort") VALUES
|
||||||
|
('feature', 'Feature', 'Feature', '#2b4a9b', 1),
|
||||||
|
('improvement', 'Verbesserung', 'Improvement', '#2e7d5b', 2),
|
||||||
|
('fix', 'Fix', 'Fix', '#b4761a', 3),
|
||||||
|
('breaking', 'Breaking', 'Breaking', '#c43a22', 4),
|
||||||
|
('info', 'Info', 'Info', '#4a5560', 5);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION post_type_default() RETURNS uuid LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT "id" FROM "post_type" ORDER BY "is_active" DESC, "sort", "key" LIMIT 1
|
||||||
|
$$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "api_idempotency_key" (
|
||||||
|
"client_id" uuid NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"post_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "api_idempotency_key_client_id_key_pk" PRIMARY KEY("client_id","key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "post" ADD COLUMN "type_id" uuid DEFAULT post_type_default() NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE "post" SET "type_id" = "post_type"."id" FROM "post_type" WHERE "post_type"."key" = "post"."type"::text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_idempotency_key" ADD CONSTRAINT "api_idempotency_key_client_id_api_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."api_client"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_idempotency_key" ADD CONSTRAINT "api_idempotency_key_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "post_type_sort" ON "post_type" USING btree ("sort","key");--> statement-breakpoint
|
||||||
|
ALTER TABLE "post" ADD CONSTRAINT "post_type_id_post_type_id_fk" FOREIGN KEY ("type_id") REFERENCES "public"."post_type"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "post" DROP COLUMN "type";--> statement-breakpoint
|
||||||
|
DROP TYPE "public"."post_type_legacy";
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"id": "4fd0842f-19f7-4076-a83a-4bf4b670b345",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.brand": {
|
||||||
|
"name": "brand",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'#191c20'"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"name": "sort",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"brand_slug_unique": {
|
||||||
|
"name": "brand_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.project": {
|
||||||
|
"name": "project",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"brand_id": {
|
||||||
|
"name": "brand_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'#191c20'"
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"name": "sort",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"project_brand_id_brand_id_fk": {
|
||||||
|
"name": "project_brand_id_brand_id_fk",
|
||||||
|
"tableFrom": "project",
|
||||||
|
"tableTo": "brand",
|
||||||
|
"columnsFrom": [
|
||||||
|
"brand_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"project_slug_unique": {
|
||||||
|
"name": "project_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
{
|
||||||
|
"id": "45004fc9-b81a-44cf-926b-b1b9beff3fab",
|
||||||
|
"prevId": "4fd0842f-19f7-4076-a83a-4bf4b670b345",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.brand": {
|
||||||
|
"name": "brand",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'#191c20'"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"name": "sort",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"brand_slug_unique": {
|
||||||
|
"name": "brand_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.project": {
|
||||||
|
"name": "project",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"brand_id": {
|
||||||
|
"name": "brand_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'#191c20'"
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"name": "sort",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"project_brand_id_brand_id_fk": {
|
||||||
|
"name": "project_brand_id_brand_id_fk",
|
||||||
|
"tableFrom": "project",
|
||||||
|
"tableTo": "brand",
|
||||||
|
"columnsFrom": [
|
||||||
|
"brand_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"project_slug_unique": {
|
||||||
|
"name": "project_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.media": {
|
||||||
|
"name": "media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "media_kind",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'image'"
|
||||||
|
},
|
||||||
|
"original_filename": {
|
||||||
|
"name": "original_filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"mime": {
|
||||||
|
"name": "mime",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"name": "width",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"name": "height",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"byte_size": {
|
||||||
|
"name": "byte_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"variants": {
|
||||||
|
"name": "variants",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'[]'::jsonb"
|
||||||
|
},
|
||||||
|
"alt": {
|
||||||
|
"name": "alt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"caption": {
|
||||||
|
"name": "caption",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"media_project_id_project_id_fk": {
|
||||||
|
"name": "media_project_id_project_id_fk",
|
||||||
|
"tableFrom": "media",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.block": {
|
||||||
|
"name": "block",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"post_id": {
|
||||||
|
"name": "post_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"name": "sort",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"block_post_sort": {
|
||||||
|
"name": "block_post_sort",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "post_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "sort",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"block_post_id_post_id_fk": {
|
||||||
|
"name": "block_post_id_post_id_fk",
|
||||||
|
"tableFrom": "block",
|
||||||
|
"tableTo": "post",
|
||||||
|
"columnsFrom": [
|
||||||
|
"post_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.issue": {
|
||||||
|
"name": "issue",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"period_from": {
|
||||||
|
"name": "period_from",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"period_to": {
|
||||||
|
"name": "period_to",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "issue_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'draft'"
|
||||||
|
},
|
||||||
|
"publish_at": {
|
||||||
|
"name": "publish_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"issue_project_id_project_id_fk": {
|
||||||
|
"name": "issue_project_id_project_id_fk",
|
||||||
|
"tableFrom": "issue",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.post_tag": {
|
||||||
|
"name": "post_tag",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"post_id": {
|
||||||
|
"name": "post_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"post_tag_post_id_post_id_fk": {
|
||||||
|
"name": "post_tag_post_id_post_id_fk",
|
||||||
|
"tableFrom": "post_tag",
|
||||||
|
"tableTo": "post",
|
||||||
|
"columnsFrom": [
|
||||||
|
"post_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"post_tag_tag_id_tag_id_fk": {
|
||||||
|
"name": "post_tag_tag_id_tag_id_fk",
|
||||||
|
"tableFrom": "post_tag",
|
||||||
|
"tableTo": "tag",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"post_tag_post_id_tag_id_pk": {
|
||||||
|
"name": "post_tag_post_id_tag_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"post_id",
|
||||||
|
"tag_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.post": {
|
||||||
|
"name": "post",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"issue_id": {
|
||||||
|
"name": "issue_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"name": "number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"teaser": {
|
||||||
|
"name": "teaser",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "post_type",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'feature'"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"name": "audience",
|
||||||
|
"type": "post_audience",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'internal'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "post_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'draft'"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "locale",
|
||||||
|
"type": "post_locale",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'de'"
|
||||||
|
},
|
||||||
|
"translation_group": {
|
||||||
|
"name": "translation_group",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"publish_at": {
|
||||||
|
"name": "publish_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"cover_media_id": {
|
||||||
|
"name": "cover_media_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"post_publish_at": {
|
||||||
|
"name": "post_publish_at",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "publish_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"post_project_status": {
|
||||||
|
"name": "post_project_status",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "project_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"post_project_id_project_id_fk": {
|
||||||
|
"name": "post_project_id_project_id_fk",
|
||||||
|
"tableFrom": "post",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"post_issue_id_issue_id_fk": {
|
||||||
|
"name": "post_issue_id_issue_id_fk",
|
||||||
|
"tableFrom": "post",
|
||||||
|
"tableTo": "issue",
|
||||||
|
"columnsFrom": [
|
||||||
|
"issue_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"post_cover_media_id_media_id_fk": {
|
||||||
|
"name": "post_cover_media_id_media_id_fk",
|
||||||
|
"tableFrom": "post",
|
||||||
|
"tableTo": "media",
|
||||||
|
"columnsFrom": [
|
||||||
|
"cover_media_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"post_project_slug": {
|
||||||
|
"name": "post_project_slug",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post_project_number": {
|
||||||
|
"name": "post_project_number",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"number"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.tag": {
|
||||||
|
"name": "tag",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tag_project_id_project_id_fk": {
|
||||||
|
"name": "tag_project_id_project_id_fk",
|
||||||
|
"tableFrom": "tag",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"tag_project_slug": {
|
||||||
|
"name": "tag_project_slug",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.media_kind": {
|
||||||
|
"name": "media_kind",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.issue_status": {
|
||||||
|
"name": "issue_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"draft",
|
||||||
|
"scheduled",
|
||||||
|
"published"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.post_audience": {
|
||||||
|
"name": "post_audience",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"internal",
|
||||||
|
"customer",
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.post_locale": {
|
||||||
|
"name": "post_locale",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"de",
|
||||||
|
"en"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.post_status": {
|
||||||
|
"name": "post_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"draft",
|
||||||
|
"review",
|
||||||
|
"scheduled",
|
||||||
|
"published",
|
||||||
|
"archived"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.post_type": {
|
||||||
|
"name": "post_type",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"feature",
|
||||||
|
"improvement",
|
||||||
|
"fix",
|
||||||
|
"breaking",
|
||||||
|
"info"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785406437281,
|
||||||
|
"tag": "0000_tiny_madrox",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785406986554,
|
||||||
|
"tag": "0001_serious_echo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785407042880,
|
||||||
|
"tag": "0002_glamorous_wild_child",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785414345463,
|
||||||
|
"tag": "0003_acoustic_sabra",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785418382530,
|
||||||
|
"tag": "0004_white_groot",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785419585654,
|
||||||
|
"tag": "0005_eager_magdalene",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785420193716,
|
||||||
|
"tag": "0006_stiff_living_tribunal",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785421669487,
|
||||||
|
"tag": "0007_parallel_doctor_doom",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785502259896,
|
||||||
|
"tag": "0008_true_spot",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785502444366,
|
||||||
|
"tag": "0009_clean_mac_gargan",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "Logbuch",
|
||||||
|
"tagline": "Was zuletzt passiert ist, pro Projekt."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"newEntry": "Eintrag schreiben",
|
||||||
|
"skipToContent": "Zum Inhalt springen"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"search": "Suchen",
|
||||||
|
"archive": "Nach Monat",
|
||||||
|
"sidebar": "Hauptnavigation",
|
||||||
|
"openMenu": "Menü öffnen",
|
||||||
|
"closeMenu": "Menü schließen",
|
||||||
|
"state": "Stand {number}",
|
||||||
|
"admin": "Verwaltung",
|
||||||
|
"toArchive": "Zum Logbuch"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Zurück",
|
||||||
|
"next": "Vor",
|
||||||
|
"status": "{from} - {to} von {total}"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Suche",
|
||||||
|
"label": "Suchbegriff",
|
||||||
|
"placeholder": "Titel und Anreißer durchsuchen",
|
||||||
|
"submit": "Suchen",
|
||||||
|
"prompt": "Gib einen Suchbegriff ein.",
|
||||||
|
"found": "Treffer für „{query}“",
|
||||||
|
"empty": "Kein Eintrag zu „{query}“ gefunden. Versuch ein einzelnes Wort oder sieh dir die neuesten Einträge an.",
|
||||||
|
"emptyTitle": "Kein Treffer",
|
||||||
|
"promptTitle": "Wonach suchst du?",
|
||||||
|
"intro": "Durchsucht Titel und Anreißer aller veröffentlichten Einträge.",
|
||||||
|
"suggestions": "Zuletzt erschienen"
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"fields": {
|
||||||
|
"project": "Projekt",
|
||||||
|
"published": "Veröffentlicht",
|
||||||
|
"type": "Typ",
|
||||||
|
"author": "Autor",
|
||||||
|
"images": "Bilder",
|
||||||
|
"number": "Nummer"
|
||||||
|
},
|
||||||
|
"content": "Inhalt",
|
||||||
|
"empty": "Dieser Eintrag hat noch keinen Inhalt.",
|
||||||
|
"back": "Alle Einträge von {project}",
|
||||||
|
"details": "Angaben",
|
||||||
|
"readingTime": "{minutes} Min. Lesezeit",
|
||||||
|
"emptyTitle": "Noch kein Inhalt"
|
||||||
|
},
|
||||||
|
"block": {
|
||||||
|
"before": "Vorher",
|
||||||
|
"after": "Nachher",
|
||||||
|
"video": "Video ansehen",
|
||||||
|
"image": "Bild",
|
||||||
|
"gallery": "Galerie",
|
||||||
|
"open": "Bild vergrößern",
|
||||||
|
"close": "Schließen",
|
||||||
|
"previous": "Vorheriges Bild",
|
||||||
|
"next": "Nächstes Bild",
|
||||||
|
"position": "{index} von {total}",
|
||||||
|
"compare": "Vergleich verschieben",
|
||||||
|
"comparePosition": "{percent} Prozent vorher"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Diese Seite steht nicht im Logbuch.",
|
||||||
|
"hint": "Diese Adresse gibt es nicht, oder der Eintrag ist nicht veröffentlicht.",
|
||||||
|
"home": "Zur Übersicht",
|
||||||
|
"next": "Von der Übersicht aus erreichst du jedes Projekt und jeden Eintrag. Wenn du einen bestimmten Eintrag suchst, hilft die Suche weiter.",
|
||||||
|
"nextTitle": "Was jetzt hilft",
|
||||||
|
"code": "404"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Farbmodus wechseln",
|
||||||
|
"light": "Hell",
|
||||||
|
"dark": "Dunkel",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Was zuletzt passiert ist.",
|
||||||
|
"intro": "Alle zwei Wochen halten wir pro Projekt fest, was fertig geworden ist. Mit Bildern, in ganzen Sätzen, ohne Ticketnummern-Kauderwelsch.",
|
||||||
|
"allProjects": "Alle Projekte",
|
||||||
|
"empty": "Noch ist kein Beitrag veröffentlicht. Leg in der Verwaltung ein Projekt an und schreib den ersten Eintrag.",
|
||||||
|
"fields": {
|
||||||
|
"brands": "Flotte",
|
||||||
|
"period": "Zeitraum",
|
||||||
|
"entries": "Einträge",
|
||||||
|
"updated": "Stand"
|
||||||
|
},
|
||||||
|
"entryCount": "{total} gesamt, {recent} in den letzten {days} Tagen",
|
||||||
|
"period": "{from} - {to}",
|
||||||
|
"emptyTitle": "Noch nichts veröffentlicht",
|
||||||
|
"emptyFilteredTitle": "Nichts in diesem Zeitraum",
|
||||||
|
"emptyFiltered": "In diesem Zeitraum ist kein Eintrag erschienen. Setze die Eingrenzung zurück oder wähle einen anderen Monat.",
|
||||||
|
"emptyTypeTitle": "Nichts von dieser Art",
|
||||||
|
"emptyType": "Von dieser Art gibt es hier keinen Eintrag. Wähle eine andere Art oder zeig alle Einträge.",
|
||||||
|
"updated": "Zuletzt {date}",
|
||||||
|
"toAdmin": "Zur Verwaltung",
|
||||||
|
"emptyProject": "In diesem Projekt ist noch kein Eintrag veröffentlicht. Sieh dir die anderen Projekte an oder schreib den ersten Eintrag in der Verwaltung.",
|
||||||
|
"emptyPageTitle": "Diese Seite gibt es nicht",
|
||||||
|
"emptyPage": "So weit reicht die Liste nicht. Geh zurück auf die erste Seite.",
|
||||||
|
"firstPage": "Zur ersten Seite"
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"latest": "Zuletzt",
|
||||||
|
"older": "Ältere Einträge",
|
||||||
|
"summary": "{count, plural, one {# Eintrag} other {# Einträge}} · {days} Tage",
|
||||||
|
"new": "Neu",
|
||||||
|
"read": "gelesen",
|
||||||
|
"unread": "ungelesen",
|
||||||
|
"number": "Eintrag {number}",
|
||||||
|
"images": "{count, plural, one {# Bild} other {# Bilder}}",
|
||||||
|
"next": "Weitere Einträge"
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"title": "Archiv",
|
||||||
|
"all": "Alle Einträge",
|
||||||
|
"filtered": "Eingegrenzt auf {period}",
|
||||||
|
"entries": "{count, plural, one {# Eintrag} other {# Einträge}}",
|
||||||
|
"months": {
|
||||||
|
"1": "Jan",
|
||||||
|
"2": "Feb",
|
||||||
|
"3": "Mär",
|
||||||
|
"4": "Apr",
|
||||||
|
"5": "Mai",
|
||||||
|
"6": "Jun",
|
||||||
|
"7": "Jul",
|
||||||
|
"8": "Aug",
|
||||||
|
"9": "Sep",
|
||||||
|
"10": "Okt",
|
||||||
|
"11": "Nov",
|
||||||
|
"12": "Dez"
|
||||||
|
},
|
||||||
|
"firstPage": "Zur ersten Seite"
|
||||||
|
},
|
||||||
|
"inapp": {
|
||||||
|
"title": "In der Anwendung",
|
||||||
|
"hint": "Dieselben Einträge holt sich jede App über die API und zeigt sie im eigenen Design. Gelesen wird pro Nutzer der jeweiligen App gemerkt.",
|
||||||
|
"markAllRead": "Alles gelesen",
|
||||||
|
"open": "Im Logbuch öffnen",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"state": "Stand {date}"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Anmelden",
|
||||||
|
"intro": "Melde dich an, um Projekte anzulegen und Beiträge zu schreiben.",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"password": "Passwort",
|
||||||
|
"submit": "Anmelden",
|
||||||
|
"pending": "Wird geprüft",
|
||||||
|
"back": "Zurück zum Logbuch",
|
||||||
|
"noAccount": "Konten legt die Verwaltung an. Wenn du noch keins hast, frag die Person, die das Logbuch betreut.",
|
||||||
|
"errors": {
|
||||||
|
"email": "Trag deine E-Mail-Adresse ein, so wie sie beim Konto hinterlegt ist.",
|
||||||
|
"password": "Trag dein Passwort ein.",
|
||||||
|
"credentials": "E-Mail-Adresse und Passwort passen nicht zusammen. Prüfe beides und versuch es noch einmal.",
|
||||||
|
"tooMany": "Zu viele Versuche kurz hintereinander. Warte eine Minute und versuch es dann erneut.",
|
||||||
|
"unknown": "Die Anmeldung hat gerade nicht geklappt. Versuch es in einem Moment noch einmal."
|
||||||
|
},
|
||||||
|
"magic": {
|
||||||
|
"title": "Mit Anmeldelink",
|
||||||
|
"intro": "Trag deine Adresse ein. Du bekommst einen Link per E-Mail, mit dem du dich ohne Passwort anmeldest.",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"submit": "Link schicken",
|
||||||
|
"pending": "Wird geschickt",
|
||||||
|
"sentTitle": "Der Link ist unterwegs",
|
||||||
|
"sent": "Wenn {email} sich anmelden darf, liegt gleich eine E-Mail mit dem Anmeldelink im Postfach.",
|
||||||
|
"sentHint": "Der Link gilt {minutes} Minuten und lässt sich einmal verwenden. Wenn nichts ankommt, sieh im Spam-Ordner nach.",
|
||||||
|
"again": "Andere Adresse verwenden",
|
||||||
|
"passwordTitle": "Lieber mit Passwort anmelden",
|
||||||
|
"linkFailed": "Dieser Anmeldelink gilt nicht mehr. Er läuft nach {minutes} Minuten ab und lässt sich nur einmal verwenden. Fordere einen neuen Link an.",
|
||||||
|
"errors": {
|
||||||
|
"email": "Trag eine vollständige E-Mail-Adresse ein, zum Beispiel name@nyo.de.",
|
||||||
|
"tooMany": "Für diese Adresse wurden gerade zu viele Links angefordert. Warte {minutes} Minuten und versuch es dann noch einmal."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Verwaltung",
|
||||||
|
"intro": "Hier legst du Projekte an und schreibst Beiträge.",
|
||||||
|
"signedInAs": "Angemeldet als {name}",
|
||||||
|
"signOut": "Abmelden",
|
||||||
|
"toArchive": "Zum Logbuch",
|
||||||
|
"projects": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Projekte",
|
||||||
|
"intro": "Ein Projekt ist ein eigener Bereich im Logbuch, mit eigener Farbe, eigenem Kürzel und eigener Nummernreihe.",
|
||||||
|
"create": "Projekt anlegen",
|
||||||
|
"createTitle": "Neues Projekt",
|
||||||
|
"createIntro": "Name und Marke reichen zum Anfangen. Der Slug wird aus dem Namen vorgeschlagen und bleibt änderbar.",
|
||||||
|
"editIntro": "Änderungen gelten sofort, auch im Archiv.",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird gespeichert",
|
||||||
|
"back": "Zurück zur Liste",
|
||||||
|
"toArchive": "Im Archiv ansehen",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Stillgelegt",
|
||||||
|
"emptyTitle": "Noch kein Projekt",
|
||||||
|
"empty": "Es gibt noch kein Projekt. Lege das erste an, dann kannst du Beiträge und Bilder dafür anlegen.",
|
||||||
|
"noBrandsTitle": "Zuerst eine Marke",
|
||||||
|
"noBrands": "Ein Projekt gehört immer zu einer Marke. Lege zuerst eine Marke an, dann kannst du das Projekt anlegen.",
|
||||||
|
"toBrands": "Marke anlegen",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"code": "Kürzel",
|
||||||
|
"brand": "Marke",
|
||||||
|
"brandMissing": "Unbekannt",
|
||||||
|
"color": "Farbe",
|
||||||
|
"posts": "Beiträge",
|
||||||
|
"state": "Status",
|
||||||
|
"edit": "Bearbeiten"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"code": "Kürzel",
|
||||||
|
"brand": "Marke",
|
||||||
|
"brandEmpty": "Marke wählen",
|
||||||
|
"color": "Farbe",
|
||||||
|
"colorPicker": "Farbe auswählen",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"active": "Projekt ist aktiv"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"slug": "Teil der Adresse im Archiv, zum Beispiel /trakk. Kleinbuchstaben, Ziffern und Bindestriche.",
|
||||||
|
"code": "Steht auf der Kennplatte, höchstens vier Zeichen. Leer lassen, dann wird es aus dem Slug abgeleitet.",
|
||||||
|
"color": "Trägt Kennplatte und Balken. Als Hexwert, zum Beispiel #2e7d5b.",
|
||||||
|
"sort": "Kleinere Zahlen stehen weiter oben.",
|
||||||
|
"description": "Ein bis zwei Sätze, wofür das Projekt steht.",
|
||||||
|
"active": "Ein stillgelegtes Projekt erscheint weder im Archiv noch in der API."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectsMeta": "{count, plural, =0 {kein Projekt} one {# Projekt} other {# Projekte}}",
|
||||||
|
"emptyTitle": "Noch kein Projekt",
|
||||||
|
"empty": "Für dich ist noch kein Projekt freigeschaltet oder es ist noch keins angelegt.",
|
||||||
|
"role": {
|
||||||
|
"admin": "Verwaltung",
|
||||||
|
"moderator": "Moderator"
|
||||||
|
},
|
||||||
|
"noAccess": {
|
||||||
|
"title": "Noch nicht freigeschaltet",
|
||||||
|
"forNobody": "Dein Konto gibt es, aber es ist für kein Projekt freigeschaltet. Bitte die Person, die das Logbuch betreut, dich als Moderator für dein Projekt einzutragen.",
|
||||||
|
"forModerator": "Dieser Teil der Verwaltung ist Konten mit Verwaltungsrechten vorbehalten. Deine Projekte findest du auf der Übersicht.",
|
||||||
|
"toOverview": "Zur Übersicht",
|
||||||
|
"wrongAccount": "Falsches Konto? Melde dich ab und mit dem richtigen wieder an."
|
||||||
|
},
|
||||||
|
"yourProjects": "Deine Projekte",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"inactive": "Stillgelegt",
|
||||||
|
"emptyForAdmin": "Es gibt noch kein Projekt. Lege eins an, dann kannst du Beiträge und Bilder dafür anlegen.",
|
||||||
|
"emptyAction": "Projekt anlegen",
|
||||||
|
"nav": {
|
||||||
|
"label": "Bereiche der Verwaltung",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"posts": "Beiträge",
|
||||||
|
"media": "Medien",
|
||||||
|
"projects": "Projekte",
|
||||||
|
"brands": "Marken",
|
||||||
|
"users": "Benutzer",
|
||||||
|
"clients": "Zugänge",
|
||||||
|
"manage": "Verwalten",
|
||||||
|
"postTypes": "Beitragsarten",
|
||||||
|
"api": "API"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "Angelegt.",
|
||||||
|
"updated": "Gespeichert.",
|
||||||
|
"forbidden": "Dafür fehlt deinem Konto das Recht. Nur die Verwaltung darf das ändern.",
|
||||||
|
"notFound": "Der Datensatz ist nicht mehr da. Lade die Seite neu.",
|
||||||
|
"invalid": "Bitte prüfe die markierten Felder.",
|
||||||
|
"slugTaken": "Diesen Slug gibt es schon. Wähle einen anderen.",
|
||||||
|
"selfAdmin": "Das eigene Verwaltungsrecht kannst du nicht ändern. Das muss ein anderes Verwaltungskonto tun.",
|
||||||
|
"typeInUse": "Diese Art wird noch benutzt. Nimm stattdessen den Haken bei Aktiv heraus."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Trag einen Namen ein.",
|
||||||
|
"slugInvalid": "Der Slug besteht aus Kleinbuchstaben, Ziffern und Bindestrichen, mindestens zwei Zeichen.",
|
||||||
|
"slugTaken": "Dieser Slug ist vergeben. Wähle einen anderen.",
|
||||||
|
"codeInvalid": "Das Kürzel hat höchstens vier Zeichen, nur Buchstaben und Ziffern.",
|
||||||
|
"colorInvalid": "Die Farbe braucht die Form #rrggbb.",
|
||||||
|
"brandUnknown": "Wähle eine Marke aus.",
|
||||||
|
"projectUnknown": "Wähle ein Projekt aus oder lass den Zugang für alle Projekte gelten.",
|
||||||
|
"sortInvalid": "Die Sortierung ist eine ganze Zahl von 0 bis 9999.",
|
||||||
|
"modeInvalid": "Wähle lesen oder schreiben.",
|
||||||
|
"scopeInvalid": "Wähle eine Zielgruppe.",
|
||||||
|
"tooLong": "Der Text ist zu lang.",
|
||||||
|
"invalid": "Dieser Wert passt nicht."
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Beiträge",
|
||||||
|
"intro": "Titel eintippen, losschreiben, veröffentlichen. Projekt, Art und Zielgruppe sind vorbelegt.",
|
||||||
|
"listTitle": "Alle Beiträge",
|
||||||
|
"create": "Neuer Beitrag",
|
||||||
|
"createTitle": "Neuer Beitrag",
|
||||||
|
"editTitle": "Beitrag",
|
||||||
|
"back": "Zur Liste",
|
||||||
|
"meta": "{count, plural, =0 {kein Beitrag} one {# Beitrag} other {# Beiträge}}",
|
||||||
|
"noAuthor": "ohne Autor",
|
||||||
|
"emptyTitle": "Noch kein Beitrag",
|
||||||
|
"empty": "In deinen Projekten steht noch kein Beitrag. Schreib den ersten, das dauert keine zwei Minuten.",
|
||||||
|
"emptyFilteredTitle": "Kein Treffer",
|
||||||
|
"emptyFiltered": "Zu dieser Eingrenzung gibt es keinen Beitrag. Setz die Eingrenzung zurück oder ändere sie.",
|
||||||
|
"noProjectsTitle": "Noch kein Projekt",
|
||||||
|
"noProjects": "Für dich ist noch kein Projekt freigeschaltet. Bitte die Verwaltung, dich als Moderator einzutragen.",
|
||||||
|
"noProjectsForAdmin": "Es gibt noch kein Projekt. Lege eins an, dann kannst du Beiträge dafür schreiben.",
|
||||||
|
"toProjects": "Projekt anlegen",
|
||||||
|
"columns": {
|
||||||
|
"number": "Nummer",
|
||||||
|
"title": "Titel",
|
||||||
|
"project": "Projekt",
|
||||||
|
"type": "Art",
|
||||||
|
"audience": "Zielgruppe",
|
||||||
|
"status": "Status",
|
||||||
|
"date": "Datum",
|
||||||
|
"author": "Autor"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Zurück",
|
||||||
|
"next": "Vor",
|
||||||
|
"status": "Seite {page} von {pages}"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"project": "Projekt",
|
||||||
|
"allProjects": "Alle Projekte",
|
||||||
|
"status": "Status",
|
||||||
|
"allStatus": "Jeder Status",
|
||||||
|
"type": "Art",
|
||||||
|
"allTypes": "Alle Arten",
|
||||||
|
"search": "Suche",
|
||||||
|
"searchPlaceholder": "Titel und Anreißer",
|
||||||
|
"submit": "Anwenden",
|
||||||
|
"reset": "Eingrenzung zurücksetzen"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"review": "Prüfung",
|
||||||
|
"scheduled": "Geplant",
|
||||||
|
"published": "Veröffentlicht",
|
||||||
|
"archived": "Zurückgezogen"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"internal": "Intern",
|
||||||
|
"customer": "Kunde",
|
||||||
|
"public": "Öffentlich"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"title": "Titel",
|
||||||
|
"titlePlaceholder": "Worum geht es?",
|
||||||
|
"teaser": "Anreißer",
|
||||||
|
"teaserPlaceholder": "Zwei Sätze, die den Beitrag zusammenfassen.",
|
||||||
|
"project": "Projekt",
|
||||||
|
"type": "Art",
|
||||||
|
"audience": "Zielgruppe",
|
||||||
|
"slug": "Slug",
|
||||||
|
"cover": "Aufmacherbild"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"audience": "Kunde sieht keine internen Beiträge, öffentlich sehen alle.",
|
||||||
|
"teaser": "Steht in der Liste und im Aufmacher. Leer lassen geht auch.",
|
||||||
|
"slug": "Teil der Adresse im Archiv. Leer lassen, dann wird er aus dem Titel gebildet.",
|
||||||
|
"cover": "Steht groß über dem Beitrag. Ohne Bild trägt die Kennplatte den Platz.",
|
||||||
|
"drop": "Bilder hierher ziehen oder mit Strg+V einfügen, auch mehrere auf einmal.",
|
||||||
|
"schedule": "Ein Termin in der Zukunft. Der Beitrag erscheint automatisch, sobald er erreicht ist.",
|
||||||
|
"projectMove": "Beim Wechsel bekommt der Beitrag die nächste freie Nummer im Zielprojekt."
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"idle": "Noch nichts gespeichert",
|
||||||
|
"pending": "Wird gespeichert",
|
||||||
|
"dirty": "Nicht gespeicherte Änderungen",
|
||||||
|
"saved": "Gespeichert um {time}",
|
||||||
|
"stored": "Zuletzt gespeichert {time}"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"title": "Termin"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"publish": "Veröffentlichen",
|
||||||
|
"publishing": "Wird veröffentlicht",
|
||||||
|
"schedule": "Termin setzen",
|
||||||
|
"unschedule": "Termin zurücknehmen",
|
||||||
|
"archive": "Zurückziehen",
|
||||||
|
"resume": "Wieder als Entwurf",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"addBlock": "Block hinzufügen",
|
||||||
|
"remove": "Block entfernen",
|
||||||
|
"up": "Nach oben",
|
||||||
|
"down": "Nach unten",
|
||||||
|
"drag": "Block verschieben",
|
||||||
|
"choose": "Bild wählen",
|
||||||
|
"change": "Anderes Bild",
|
||||||
|
"clear": "Bild entfernen",
|
||||||
|
"upload": "Hochladen",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"text": "Text",
|
||||||
|
"image": "Bild",
|
||||||
|
"gallery": "Galerie",
|
||||||
|
"before_after": "Vorher und nachher",
|
||||||
|
"video": "Video",
|
||||||
|
"quote": "Zitat",
|
||||||
|
"code": "Code",
|
||||||
|
"link": "Verweis",
|
||||||
|
"callout": "Hinweis"
|
||||||
|
},
|
||||||
|
"blockFields": {
|
||||||
|
"text": "Text",
|
||||||
|
"textPlaceholder": "Schreib in ganzen Sätzen. Eine Leerzeile trennt Absätze.",
|
||||||
|
"image": "Bild",
|
||||||
|
"gallery": "Bilder",
|
||||||
|
"galleryHint": "Die Reihenfolge entspricht der Auswahl.",
|
||||||
|
"before": "Vorher",
|
||||||
|
"after": "Nachher",
|
||||||
|
"video": "Videodatei",
|
||||||
|
"videoHint": "Ohne Datei genügt ein Verweis auf das Video.",
|
||||||
|
"url": "Adresse",
|
||||||
|
"label": "Beschriftung",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"quote": "Zitat",
|
||||||
|
"source": "Quelle",
|
||||||
|
"language": "Sprache",
|
||||||
|
"code": "Code",
|
||||||
|
"tone": "Ton",
|
||||||
|
"toneInfo": "Hinweis",
|
||||||
|
"toneWarning": "Warnung"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"empty": "In diesem Projekt liegt noch kein Bild. Lade eins hoch oder zieh es in den Editor.",
|
||||||
|
"altMissing": "ohne Alt-Text"
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"pending": "{name} wird hochgeladen",
|
||||||
|
"done": "{name} ist hochgeladen",
|
||||||
|
"failed": "{name}: {reason}"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"scope": "Ansicht als",
|
||||||
|
"untitled": "Ohne Titel",
|
||||||
|
"empty": "Dieser Beitrag hat noch keinen Inhalt.",
|
||||||
|
"hiddenTitle": "Für diese Zielgruppe unsichtbar",
|
||||||
|
"hidden": "Als {scope} ist dieser Beitrag nicht zu sehen, er ist für {audience} bestimmt."
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"blockersTitle": "Das hält das Veröffentlichen auf",
|
||||||
|
"title_missing": "Der Titel fehlt.",
|
||||||
|
"content_empty": "Der Beitrag hat noch keinen Inhalt.",
|
||||||
|
"alt_text_missing": "Ein Bild hat keinen Alt-Text. Bei öffentlichen Beiträgen ist er Pflicht.",
|
||||||
|
"tags_missing": "Noch kein Schlagwort vergeben.",
|
||||||
|
"cover_missing": "Noch kein Aufmacherbild gesetzt."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"forbidden": "Dafür fehlt deinem Konto das Recht.",
|
||||||
|
"projectUnknown": "Dieses Projekt gibt es nicht.",
|
||||||
|
"typeUnknown": "Diese Beitragsart steht nicht mehr zur Auswahl. Wähle eine andere.",
|
||||||
|
"projectLocked": "Das Projekt lässt sich nach dem Anlegen nicht mehr wechseln.",
|
||||||
|
"notFound": "Diesen Beitrag gibt es nicht mehr. Lade die Seite neu.",
|
||||||
|
"titleMissing": "Trag zuerst einen Titel ein.",
|
||||||
|
"titleTooLong": "Der Titel ist zu lang.",
|
||||||
|
"teaserTooLong": "Der Anreißer ist zu lang.",
|
||||||
|
"slugInvalid": "Der Slug besteht aus Kleinbuchstaben, Ziffern und Bindestrichen, mindestens zwei Zeichen.",
|
||||||
|
"slugTaken": "Diesen Slug gibt es in diesem Projekt schon.",
|
||||||
|
"blocksInvalid": "Ein Block ist unvollständig. Prüfe die Adressen und die Bilder.",
|
||||||
|
"blocked": "Veröffentlichen geht noch nicht.",
|
||||||
|
"statusInvalid": "Dieser Schritt ist aus dem aktuellen Status nicht möglich.",
|
||||||
|
"scheduleMissing": "Wähle einen Termin aus.",
|
||||||
|
"schedulePast": "Der Termin muss in der Zukunft liegen.",
|
||||||
|
"uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal.",
|
||||||
|
"fileMissing": "Wähle eine Bilddatei aus.",
|
||||||
|
"fileTooLarge": "Die Datei ist größer als 15 MB. Verkleinere sie und versuch es noch einmal.",
|
||||||
|
"unsupportedType": "Dieses Format wird nicht angenommen. Nimm JPEG, PNG, WebP, AVIF, GIF oder TIFF.",
|
||||||
|
"unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.",
|
||||||
|
"unknown": "Das hat gerade nicht geklappt. Versuch es noch einmal."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"brands": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Marken",
|
||||||
|
"intro": "Marken fassen Projekte zusammen, zum Beispiel nach Firma. Jedes Projekt gehört zu genau einer Marke.",
|
||||||
|
"create": "Marke anlegen",
|
||||||
|
"createTitle": "Neue Marke",
|
||||||
|
"createIntro": "Name und Farbe genügen. Der Slug wird aus dem Namen vorgeschlagen.",
|
||||||
|
"editIntro": "Änderungen gelten sofort für alle Projekte dieser Marke.",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird gespeichert",
|
||||||
|
"back": "Zurück zur Liste",
|
||||||
|
"emptyTitle": "Noch keine Marke",
|
||||||
|
"empty": "Es gibt noch keine Marke. Lege die erste an, danach kannst du Projekte anlegen.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"color": "Farbe",
|
||||||
|
"projects": "Projekte",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"edit": "Bearbeiten"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"color": "Farbe",
|
||||||
|
"colorPicker": "Farbe auswählen",
|
||||||
|
"sort": "Sortierung"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"slug": "Kleinbuchstaben, Ziffern und Bindestriche.",
|
||||||
|
"color": "Als Hexwert, zum Beispiel #2b4a9b.",
|
||||||
|
"sort": "Kleinere Zahlen stehen weiter oben."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postTypes": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Beitragsarten",
|
||||||
|
"intro": "Beitragsarten kennzeichnen, worum es in einem Beitrag geht. Schlüssel, Bezeichnung, Farbe und Reihenfolge legst du hier fest.",
|
||||||
|
"create": "Beitragsart anlegen",
|
||||||
|
"createTitle": "Neue Beitragsart",
|
||||||
|
"createIntro": "Bezeichnung und Farbe genügen. Der Schlüssel wird aus der deutschen Bezeichnung vorgeschlagen.",
|
||||||
|
"editIntro": "Änderungen gelten sofort für alle Beiträge dieser Art.",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird gespeichert",
|
||||||
|
"back": "Zurück zur Liste",
|
||||||
|
"emptyTitle": "Noch keine Beitragsart",
|
||||||
|
"empty": "Es gibt noch keine Beitragsart. Lege die erste an, danach kannst du Beiträge einordnen.",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Stillgelegt",
|
||||||
|
"usage": "{count, plural, =0 {kein Beitrag} one {# Beitrag} other {# Beiträge}}",
|
||||||
|
"deleteTitle": "Löschen",
|
||||||
|
"delete": "Beitragsart löschen",
|
||||||
|
"deleting": "Wird gelöscht",
|
||||||
|
"deleteBlocked": "Diese Art wird von {count, plural, one {# Beitrag} other {# Beiträgen}} benutzt und lässt sich nicht löschen. Nimm den Haken bei Aktiv heraus, dann steht sie nicht mehr zur Auswahl.",
|
||||||
|
"columns": {
|
||||||
|
"label": "Bezeichnung",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"color": "Farbe",
|
||||||
|
"posts": "Beiträge",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"edit": "Bearbeiten"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"labelDe": "Bezeichnung deutsch",
|
||||||
|
"labelEn": "Bezeichnung englisch",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"color": "Farbe",
|
||||||
|
"colorPicker": "Farbe auswählen",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"isActive": "Aktiv"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"key": "Kleinbuchstaben, Ziffern und Bindestriche. Steht so in der API.",
|
||||||
|
"color": "Als Hexwert, zum Beispiel #2b4a9b. Damit wird die Art am Beitrag gekennzeichnet.",
|
||||||
|
"sort": "Kleinere Zahlen stehen weiter vorn, in der Auswahl und in den Filtern.",
|
||||||
|
"isActive": "Nur aktive Arten stehen beim Schreiben zur Auswahl.",
|
||||||
|
"activeInUse": "Nur aktive Arten stehen beim Schreiben zur Auswahl. Beiträge, die diese Art schon tragen, behalten sie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "Benutzer",
|
||||||
|
"intro": "Ein Konto braucht nur, wer schreibt. Lesen geht ohne Konto.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"role": "Recht",
|
||||||
|
"projects": "Projekte",
|
||||||
|
"edit": "Öffnen"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Verwaltung",
|
||||||
|
"moderator": "Moderator"
|
||||||
|
},
|
||||||
|
"you": "du",
|
||||||
|
"all": "alle",
|
||||||
|
"back": "Zurück zur Liste",
|
||||||
|
"saving": "Wird gespeichert",
|
||||||
|
"emptyTitle": "Noch kein Konto",
|
||||||
|
"empty": "Es gibt noch kein Konto. Leg eins mit dem Befehl pnpm admin:create an.",
|
||||||
|
"newAccountTitle": "Neues Konto",
|
||||||
|
"newAccount": "Konten entstehen über den Befehl pnpm admin:create. Danach vergibst du hier die Rechte.",
|
||||||
|
"adminSection": "Verwaltungsrecht",
|
||||||
|
"isAdmin": "Dieses Konto verwaltet alles und hat Zugriff auf jedes Projekt.",
|
||||||
|
"isNotAdmin": "Dieses Konto arbeitet nur in den Projekten, für die es freigeschaltet ist.",
|
||||||
|
"grantAdmin": "Verwaltungsrecht geben",
|
||||||
|
"revokeAdmin": "Verwaltungsrecht entziehen",
|
||||||
|
"selfHint": "Am eigenen Konto lässt sich das Verwaltungsrecht nicht ändern.",
|
||||||
|
"rolesSection": "Projekte",
|
||||||
|
"assignedMeta": "{count, plural, =0 {kein Projekt} one {# Projekt} other {# Projekte}}",
|
||||||
|
"adminHasAll": "Das Konto hat Verwaltungsrecht und damit ohnehin Zugriff auf jedes Projekt. Die Freischaltungen hier greifen, sobald das Verwaltungsrecht entzogen wird.",
|
||||||
|
"grantRole": "Freischalten",
|
||||||
|
"revokeRole": "Entziehen",
|
||||||
|
"implicit": "über Verwaltungsrecht",
|
||||||
|
"rolesHint": "Ein Moderator legt Beiträge und Bilder seiner Projekte an und veröffentlicht sie.",
|
||||||
|
"noProjectsTitle": "Noch kein Projekt",
|
||||||
|
"noProjects": "Es gibt noch kein Projekt, das du freischalten könntest.",
|
||||||
|
"toProjects": "Projekt anlegen",
|
||||||
|
"accountSection": "Konto",
|
||||||
|
"accountHint": "Name, E-Mail und Passwort setzt der Befehl pnpm admin:create."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"eyebrow": "Verwaltung",
|
||||||
|
"title": "API-Zugänge",
|
||||||
|
"intro": "Anwendungen holen Beiträge über einen Zugang. Lesende Zugänge liefern veröffentlichte Beiträge ihrer Zielgruppe, schreibende legen Entwürfe an.",
|
||||||
|
"listTitle": "Zugänge",
|
||||||
|
"meta": "{count, plural, =0 {kein Zugang} one {# Zugang} other {# Zugänge}}",
|
||||||
|
"emptyTitle": "Noch kein Zugang",
|
||||||
|
"empty": "Es gibt noch keinen Zugang. Leg unten einen an, damit eine Anwendung Beiträge abrufen kann.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"mode": "Modus",
|
||||||
|
"scope": "Zielgruppe",
|
||||||
|
"project": "Projekt",
|
||||||
|
"lastUsed": "Zuletzt genutzt",
|
||||||
|
"state": "Status",
|
||||||
|
"action": "Aktion"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"read": "Lesen",
|
||||||
|
"write": "Schreiben"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"internal": "Intern",
|
||||||
|
"customer": "Kunde",
|
||||||
|
"public": "Öffentlich"
|
||||||
|
},
|
||||||
|
"allProjects": "Alle Projekte",
|
||||||
|
"unknownProject": "Unbekannt",
|
||||||
|
"never": "noch nie",
|
||||||
|
"activeState": "Aktiv",
|
||||||
|
"revoked": "Widerrufen",
|
||||||
|
"revoke": "Widerrufen",
|
||||||
|
"revoking": "Wird widerrufen",
|
||||||
|
"createTitle": "Zugang anlegen",
|
||||||
|
"createIntro": "Das Token wird einmal angezeigt und nur als Hash gespeichert. Wer es verliert, legt einen neuen Zugang an und widerruft den alten.",
|
||||||
|
"create": "Zugang anlegen",
|
||||||
|
"creating": "Wird angelegt",
|
||||||
|
"tokenTitle": "Token, nur jetzt sichtbar",
|
||||||
|
"tokenHint": "Kopier das Token jetzt und trag es in der Anwendung ein. Später lässt es sich nicht mehr anzeigen.",
|
||||||
|
"copy": "Token kopieren",
|
||||||
|
"copied": "Kopiert",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"mode": "Modus",
|
||||||
|
"scope": "Zielgruppe",
|
||||||
|
"project": "Projekt"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"name": "Woran du diesen Zugang später erkennst, zum Beispiel Trakk Web.",
|
||||||
|
"mode": "Lesen holt Beiträge. Schreiben legt Entwürfe an und lädt Bilder hoch.",
|
||||||
|
"scope": "Bestimmt, welche Beiträge der Zugang sieht. Kunde bekommt nie interne Beiträge.",
|
||||||
|
"project": "Ohne Bindung gilt der Zugang für alle Projekte."
|
||||||
|
},
|
||||||
|
"publishTitle": "Veröffentlichen",
|
||||||
|
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"type": "Art",
|
||||||
|
"allTypes": "Alle Arten"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"eyebrow": "Bilder",
|
||||||
|
"title": "Mediathek",
|
||||||
|
"intro": "Pro Projekt ein Bildbestand. Beim Hochladen entstehen automatisch WebP-Fassungen in mehreren Breiten.",
|
||||||
|
"back": "Alle Projekte",
|
||||||
|
"library": "Bestand",
|
||||||
|
"projectIntro": "Lade Bilder hoch und pflege Alt-Text und Bildunterschrift. Ohne Alt-Text lässt sich ein öffentlicher Beitrag nicht veröffentlichen.",
|
||||||
|
"projectMeta": "{count, plural, =0 {kein Bild} one {# Bild} other {# Bilder}}",
|
||||||
|
"emptyProjectsTitle": "Noch kein Projekt",
|
||||||
|
"emptyProjects": "Für dich ist noch kein Projekt freigeschaltet oder es ist noch keins angelegt.",
|
||||||
|
"emptyLibraryTitle": "Noch kein Bild",
|
||||||
|
"emptyLibrary": "In diesem Projekt liegt noch kein Bild. Lade das erste über das Feld oben hoch.",
|
||||||
|
"upload": {
|
||||||
|
"title": "Bild hochladen",
|
||||||
|
"hint": "JPEG, PNG, WebP, AVIF, GIF oder TIFF. Das Original bleibt erhalten, die Fassungen entstehen daraus.",
|
||||||
|
"file": "Datei",
|
||||||
|
"submit": "Hochladen",
|
||||||
|
"pending": "Wird verarbeitet",
|
||||||
|
"limit": "Höchstens 15 MB"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"alt": "Alt-Text",
|
||||||
|
"altHint": "Bei öffentlichen Beiträgen Pflicht, sonst eine Empfehlung.",
|
||||||
|
"altPlaceholder": "Was ist auf dem Bild zu sehen?",
|
||||||
|
"caption": "Bildunterschrift"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"submit": "Speichern",
|
||||||
|
"pending": "Wird gespeichert"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {keine Fassung} one {# Fassung} other {# Fassungen}}",
|
||||||
|
"altMissing": "Ohne Alt-Text. Für öffentliche Beiträge ist er Pflicht.",
|
||||||
|
"variantError": "Die Fassungen konnten nicht erzeugt werden. Das Original ist gespeichert und nutzbar."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"uploaded": "Das Bild ist hochgeladen und die Fassungen sind erzeugt.",
|
||||||
|
"saved": "Alt-Text und Bildunterschrift sind gespeichert.",
|
||||||
|
"fileMissing": "Wähle eine Bilddatei aus.",
|
||||||
|
"fileTooLarge": "Die Datei ist größer als 15 MB. Verkleinere sie und versuch es noch einmal.",
|
||||||
|
"unsupportedType": "Dieses Format wird nicht angenommen. Nimm JPEG, PNG, WebP, AVIF, GIF oder TIFF.",
|
||||||
|
"unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.",
|
||||||
|
"projectUnknown": "Dieses Projekt gibt es nicht.",
|
||||||
|
"mediaUnknown": "Dieses Bild gibt es nicht mehr.",
|
||||||
|
"uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"magicLink": {
|
||||||
|
"subject": "Dein Anmeldelink für das Logbuch",
|
||||||
|
"greeting": "Hallo,",
|
||||||
|
"body": "hier ist dein Anmeldelink für das Logbuch. Er gilt {minutes} Minuten und lässt sich einmal verwenden.",
|
||||||
|
"ignore": "Wenn du keinen Link angefordert hast, ignoriere diese Nachricht. Ohne den Link passiert nichts.",
|
||||||
|
"signature": "Logbuch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "Logbuch",
|
||||||
|
"tagline": "What happened lately, per project."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"newEntry": "Write entry",
|
||||||
|
"skipToContent": "Skip to content"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"search": "Search",
|
||||||
|
"archive": "By month",
|
||||||
|
"sidebar": "Main navigation",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"state": "State {number}",
|
||||||
|
"admin": "Admin",
|
||||||
|
"toArchive": "To the log"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"status": "{from} - {to} of {total}"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Search",
|
||||||
|
"label": "Search term",
|
||||||
|
"placeholder": "Search titles and teasers",
|
||||||
|
"submit": "Search",
|
||||||
|
"prompt": "Enter a search term.",
|
||||||
|
"found": "Matches for “{query}”",
|
||||||
|
"empty": "No entry found for “{query}”. Try a single word or take a look at the latest entries.",
|
||||||
|
"emptyTitle": "No match",
|
||||||
|
"promptTitle": "What are you looking for?",
|
||||||
|
"intro": "Searches titles and teasers of all published entries.",
|
||||||
|
"suggestions": "Published lately"
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"fields": {
|
||||||
|
"project": "Project",
|
||||||
|
"published": "Published",
|
||||||
|
"type": "Type",
|
||||||
|
"author": "Author",
|
||||||
|
"images": "Images",
|
||||||
|
"number": "Number"
|
||||||
|
},
|
||||||
|
"content": "Content",
|
||||||
|
"empty": "This entry has no content yet.",
|
||||||
|
"back": "All entries of {project}",
|
||||||
|
"details": "Details",
|
||||||
|
"readingTime": "{minutes} min read",
|
||||||
|
"emptyTitle": "No content yet"
|
||||||
|
},
|
||||||
|
"block": {
|
||||||
|
"before": "Before",
|
||||||
|
"after": "After",
|
||||||
|
"video": "Watch video",
|
||||||
|
"image": "Image",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"open": "Enlarge image",
|
||||||
|
"close": "Close",
|
||||||
|
"previous": "Previous image",
|
||||||
|
"next": "Next image",
|
||||||
|
"position": "{index} of {total}",
|
||||||
|
"compare": "Move the comparison",
|
||||||
|
"comparePosition": "{percent} percent before"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "This page is not in the logbook.",
|
||||||
|
"hint": "This address does not exist, or the entry is not published.",
|
||||||
|
"home": "To the overview",
|
||||||
|
"next": "From the overview you can reach every project and every entry. If you are looking for a specific entry, the search will help.",
|
||||||
|
"nextTitle": "What helps now",
|
||||||
|
"code": "404"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Change colour mode",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "What happened lately.",
|
||||||
|
"intro": "Every two weeks we write down per project what has been finished. With images, in full sentences, without ticket number gibberish.",
|
||||||
|
"allProjects": "All projects",
|
||||||
|
"empty": "No entry published yet. Create a project in the administration and write the first entry.",
|
||||||
|
"fields": {
|
||||||
|
"brands": "Fleet",
|
||||||
|
"period": "Period",
|
||||||
|
"entries": "Entries",
|
||||||
|
"updated": "Updated"
|
||||||
|
},
|
||||||
|
"entryCount": "{total} in total, {recent} within the last {days} days",
|
||||||
|
"period": "{from} - {to}",
|
||||||
|
"emptyTitle": "Nothing published yet",
|
||||||
|
"emptyFilteredTitle": "Nothing in this period",
|
||||||
|
"emptyFiltered": "No entry appeared in this period. Reset the filter or pick another month.",
|
||||||
|
"emptyTypeTitle": "Nothing of this kind",
|
||||||
|
"emptyType": "There is no entry of this kind here. Pick another kind or show all entries.",
|
||||||
|
"updated": "Last {date}",
|
||||||
|
"toAdmin": "To the administration",
|
||||||
|
"emptyProject": "No entry published in this project yet. Take a look at the other projects or write the first entry in the administration.",
|
||||||
|
"emptyPageTitle": "This page does not exist",
|
||||||
|
"emptyPage": "The list does not reach that far. Go back to the first page.",
|
||||||
|
"firstPage": "To the first page"
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"latest": "Latest",
|
||||||
|
"older": "Older entries",
|
||||||
|
"summary": "{count, plural, one {# entry} other {# entries}} · {days} days",
|
||||||
|
"new": "New",
|
||||||
|
"read": "read",
|
||||||
|
"unread": "unread",
|
||||||
|
"number": "Entry {number}",
|
||||||
|
"images": "{count, plural, one {# image} other {# images}}",
|
||||||
|
"next": "More entries"
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"title": "Archive",
|
||||||
|
"all": "All entries",
|
||||||
|
"filtered": "Narrowed down to {period}",
|
||||||
|
"entries": "{count, plural, one {# entry} other {# entries}}",
|
||||||
|
"months": {
|
||||||
|
"1": "Jan",
|
||||||
|
"2": "Feb",
|
||||||
|
"3": "Mar",
|
||||||
|
"4": "Apr",
|
||||||
|
"5": "May",
|
||||||
|
"6": "Jun",
|
||||||
|
"7": "Jul",
|
||||||
|
"8": "Aug",
|
||||||
|
"9": "Sep",
|
||||||
|
"10": "Oct",
|
||||||
|
"11": "Nov",
|
||||||
|
"12": "Dec"
|
||||||
|
},
|
||||||
|
"firstPage": "To the first page"
|
||||||
|
},
|
||||||
|
"inapp": {
|
||||||
|
"title": "Inside the application",
|
||||||
|
"hint": "Every app fetches the same entries through the API and renders them in its own design. Read state is kept per user of the respective app.",
|
||||||
|
"markAllRead": "Mark all read",
|
||||||
|
"open": "Open in Logbuch",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"state": "Updated {date}"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Sign in",
|
||||||
|
"intro": "Sign in to create projects and write entries.",
|
||||||
|
"email": "Email address",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"pending": "Checking",
|
||||||
|
"back": "Back to Logbuch",
|
||||||
|
"noAccount": "Accounts are created by the administration. If you do not have one yet, ask the person who looks after Logbuch.",
|
||||||
|
"errors": {
|
||||||
|
"email": "Enter your email address exactly as it is stored on the account.",
|
||||||
|
"password": "Enter your password.",
|
||||||
|
"credentials": "Email address and password do not match. Check both and try again.",
|
||||||
|
"tooMany": "Too many attempts in a row. Wait a minute and try again.",
|
||||||
|
"unknown": "Signing in did not work just now. Try again in a moment."
|
||||||
|
},
|
||||||
|
"magic": {
|
||||||
|
"title": "With a sign-in link",
|
||||||
|
"intro": "Enter your address. You will receive a link by email that signs you in without a password.",
|
||||||
|
"email": "Email address",
|
||||||
|
"submit": "Send link",
|
||||||
|
"pending": "Sending",
|
||||||
|
"sentTitle": "The link is on its way",
|
||||||
|
"sent": "If {email} is allowed to sign in, an email with the sign-in link will arrive shortly.",
|
||||||
|
"sentHint": "The link is valid for {minutes} minutes and can be used once. If nothing arrives, check the spam folder.",
|
||||||
|
"again": "Use a different address",
|
||||||
|
"passwordTitle": "Sign in with a password instead",
|
||||||
|
"linkFailed": "This sign-in link is no longer valid. It expires after {minutes} minutes and can only be used once. Request a new link.",
|
||||||
|
"errors": {
|
||||||
|
"email": "Enter a complete email address, for example name@nyo.de.",
|
||||||
|
"tooMany": "Too many links have just been requested for this address. Wait {minutes} minutes and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Administration",
|
||||||
|
"intro": "This is where you create projects and write entries.",
|
||||||
|
"signedInAs": "Signed in as {name}",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"toArchive": "To Logbuch",
|
||||||
|
"projects": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Projects",
|
||||||
|
"intro": "A project is its own section in Logbuch, with its own colour, code and running number.",
|
||||||
|
"create": "Create project",
|
||||||
|
"createTitle": "New project",
|
||||||
|
"createIntro": "Name and brand are enough to start. The slug is suggested from the name and stays editable.",
|
||||||
|
"editIntro": "Changes take effect right away, in the archive as well.",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving",
|
||||||
|
"back": "Back to the list",
|
||||||
|
"toArchive": "View in the archive",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Retired",
|
||||||
|
"emptyTitle": "No project yet",
|
||||||
|
"empty": "There is no project yet. Create the first one, then you can add entries and images for it.",
|
||||||
|
"noBrandsTitle": "A brand first",
|
||||||
|
"noBrands": "A project always belongs to a brand. Create a brand first, then you can create the project.",
|
||||||
|
"toBrands": "Create brand",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"code": "Code",
|
||||||
|
"brand": "Brand",
|
||||||
|
"brandMissing": "Unknown",
|
||||||
|
"color": "Colour",
|
||||||
|
"posts": "Entries",
|
||||||
|
"state": "State",
|
||||||
|
"edit": "Edit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"code": "Code",
|
||||||
|
"brand": "Brand",
|
||||||
|
"brandEmpty": "Pick a brand",
|
||||||
|
"color": "Colour",
|
||||||
|
"colorPicker": "Pick a colour",
|
||||||
|
"sort": "Sorting",
|
||||||
|
"description": "Description",
|
||||||
|
"active": "Project is active"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"slug": "Part of the archive address, for example /trakk. Lower case letters, digits and hyphens.",
|
||||||
|
"code": "Shown on the plate, at most four characters. Leave empty to derive it from the slug.",
|
||||||
|
"color": "Carries plate and bar. As a hex value, for example #2e7d5b.",
|
||||||
|
"sort": "Smaller numbers come first.",
|
||||||
|
"description": "One or two sentences on what the project is about.",
|
||||||
|
"active": "A retired project shows up neither in the archive nor in the API."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectsMeta": "{count, plural, =0 {no project} one {# project} other {# projects}}",
|
||||||
|
"emptyTitle": "No project yet",
|
||||||
|
"empty": "No project has been released for you yet, or none has been created.",
|
||||||
|
"role": {
|
||||||
|
"admin": "Administration",
|
||||||
|
"moderator": "Moderator"
|
||||||
|
},
|
||||||
|
"noAccess": {
|
||||||
|
"title": "Not released yet",
|
||||||
|
"forNobody": "Your account exists, but it is not released for any project. Ask the person who looks after Logbuch to add you as a moderator for your project.",
|
||||||
|
"forModerator": "This part of the administration is reserved for accounts with administration rights. You find your projects on the overview.",
|
||||||
|
"toOverview": "To the overview",
|
||||||
|
"wrongAccount": "Wrong account? Sign out and sign in with the right one."
|
||||||
|
},
|
||||||
|
"yourProjects": "Your projects",
|
||||||
|
"edit": "Edit",
|
||||||
|
"inactive": "Retired",
|
||||||
|
"emptyForAdmin": "There is no project yet. Create one, then you can add entries and images for it.",
|
||||||
|
"emptyAction": "Create project",
|
||||||
|
"nav": {
|
||||||
|
"label": "Administration sections",
|
||||||
|
"overview": "Overview",
|
||||||
|
"posts": "Entries",
|
||||||
|
"media": "Media",
|
||||||
|
"projects": "Projects",
|
||||||
|
"brands": "Brands",
|
||||||
|
"users": "Users",
|
||||||
|
"clients": "Access",
|
||||||
|
"manage": "Manage",
|
||||||
|
"postTypes": "Post types",
|
||||||
|
"api": "API"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"created": "Created.",
|
||||||
|
"updated": "Saved.",
|
||||||
|
"forbidden": "Your account is missing the right for this. Only administration may change it.",
|
||||||
|
"notFound": "That record is gone. Reload the page.",
|
||||||
|
"invalid": "Please check the marked fields.",
|
||||||
|
"slugTaken": "That slug already exists. Pick another one.",
|
||||||
|
"selfAdmin": "You cannot change the administration right on your own account. Another administration account has to do it.",
|
||||||
|
"typeInUse": "This type is still in use. Clear the active checkbox instead."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Enter a name.",
|
||||||
|
"slugInvalid": "A slug is made of lower case letters, digits and hyphens, at least two characters.",
|
||||||
|
"slugTaken": "That slug is taken. Pick another one.",
|
||||||
|
"codeInvalid": "The code holds at most four characters, letters and digits only.",
|
||||||
|
"colorInvalid": "The colour needs the form #rrggbb.",
|
||||||
|
"brandUnknown": "Pick a brand.",
|
||||||
|
"projectUnknown": "Pick a project or let the access cover all projects.",
|
||||||
|
"sortInvalid": "Sorting is a whole number from 0 to 9999.",
|
||||||
|
"modeInvalid": "Pick read or write.",
|
||||||
|
"scopeInvalid": "Pick an audience.",
|
||||||
|
"tooLong": "That text is too long.",
|
||||||
|
"invalid": "That value does not fit."
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Entries",
|
||||||
|
"intro": "Type a title, start writing, publish. Project, type and audience come preset.",
|
||||||
|
"listTitle": "All entries",
|
||||||
|
"create": "New entry",
|
||||||
|
"createTitle": "New entry",
|
||||||
|
"editTitle": "Entry",
|
||||||
|
"back": "To the list",
|
||||||
|
"meta": "{count, plural, =0 {no entry} one {# entry} other {# entries}}",
|
||||||
|
"noAuthor": "no author",
|
||||||
|
"emptyTitle": "No entry yet",
|
||||||
|
"empty": "There is no entry in your projects yet. Write the first one, it takes less than two minutes.",
|
||||||
|
"emptyFilteredTitle": "No match",
|
||||||
|
"emptyFiltered": "No entry matches this filter. Reset it or pick different values.",
|
||||||
|
"noProjectsTitle": "No project yet",
|
||||||
|
"noProjects": "No project has been released for you yet. Ask administration to add you as a moderator.",
|
||||||
|
"noProjectsForAdmin": "There is no project yet. Create one, then you can write entries for it.",
|
||||||
|
"toProjects": "Create project",
|
||||||
|
"columns": {
|
||||||
|
"number": "Number",
|
||||||
|
"title": "Title",
|
||||||
|
"project": "Project",
|
||||||
|
"type": "Type",
|
||||||
|
"audience": "Audience",
|
||||||
|
"status": "Status",
|
||||||
|
"date": "Date",
|
||||||
|
"author": "Author"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"status": "Page {page} of {pages}"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"project": "Project",
|
||||||
|
"allProjects": "All projects",
|
||||||
|
"status": "Status",
|
||||||
|
"allStatus": "Any status",
|
||||||
|
"type": "Type",
|
||||||
|
"allTypes": "All types",
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Title and teaser",
|
||||||
|
"submit": "Apply",
|
||||||
|
"reset": "Reset filter"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"review": "Review",
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"published": "Published",
|
||||||
|
"archived": "Withdrawn"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"internal": "Internal",
|
||||||
|
"customer": "Customer",
|
||||||
|
"public": "Public"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"title": "Title",
|
||||||
|
"titlePlaceholder": "What is it about?",
|
||||||
|
"teaser": "Teaser",
|
||||||
|
"teaserPlaceholder": "Two sentences that sum up the entry.",
|
||||||
|
"project": "Project",
|
||||||
|
"type": "Type",
|
||||||
|
"audience": "Audience",
|
||||||
|
"slug": "Slug",
|
||||||
|
"cover": "Cover image"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"audience": "Customers never see internal entries, public is visible to everyone.",
|
||||||
|
"teaser": "Shown in the list and in the lead. Leaving it empty is fine.",
|
||||||
|
"slug": "Part of the address in the archive. Leave it empty to derive it from the title.",
|
||||||
|
"cover": "Sits large above the entry. Without an image the plate takes that space.",
|
||||||
|
"drop": "Drag images here or paste them with Ctrl+V, several at once works.",
|
||||||
|
"schedule": "A moment in the future. The entry appears automatically once it is reached.",
|
||||||
|
"projectMove": "On a move the entry receives the next free number in the target project."
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"idle": "Nothing saved yet",
|
||||||
|
"pending": "Saving",
|
||||||
|
"dirty": "Unsaved changes",
|
||||||
|
"saved": "Saved at {time}",
|
||||||
|
"stored": "Last saved {time}"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"title": "Schedule"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"publish": "Publish",
|
||||||
|
"publishing": "Publishing",
|
||||||
|
"schedule": "Set date",
|
||||||
|
"unschedule": "Drop date",
|
||||||
|
"archive": "Withdraw",
|
||||||
|
"resume": "Back to draft",
|
||||||
|
"preview": "Preview",
|
||||||
|
"edit": "Edit",
|
||||||
|
"addBlock": "Add block",
|
||||||
|
"remove": "Remove block",
|
||||||
|
"up": "Move up",
|
||||||
|
"down": "Move down",
|
||||||
|
"drag": "Move block",
|
||||||
|
"choose": "Choose image",
|
||||||
|
"change": "Other image",
|
||||||
|
"clear": "Remove image",
|
||||||
|
"upload": "Upload",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"text": "Text",
|
||||||
|
"image": "Image",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"before_after": "Before and after",
|
||||||
|
"video": "Video",
|
||||||
|
"quote": "Quote",
|
||||||
|
"code": "Code",
|
||||||
|
"link": "Link",
|
||||||
|
"callout": "Callout"
|
||||||
|
},
|
||||||
|
"blockFields": {
|
||||||
|
"text": "Text",
|
||||||
|
"textPlaceholder": "Write in full sentences. An empty line separates paragraphs.",
|
||||||
|
"image": "Image",
|
||||||
|
"gallery": "Images",
|
||||||
|
"galleryHint": "The order follows your selection.",
|
||||||
|
"before": "Before",
|
||||||
|
"after": "After",
|
||||||
|
"video": "Video file",
|
||||||
|
"videoHint": "Without a file a link to the video is enough.",
|
||||||
|
"url": "Address",
|
||||||
|
"label": "Label",
|
||||||
|
"description": "Description",
|
||||||
|
"quote": "Quote",
|
||||||
|
"source": "Source",
|
||||||
|
"language": "Language",
|
||||||
|
"code": "Code",
|
||||||
|
"tone": "Tone",
|
||||||
|
"toneInfo": "Note",
|
||||||
|
"toneWarning": "Warning"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"empty": "This project has no image yet. Upload one or drag it into the editor.",
|
||||||
|
"altMissing": "no alt text"
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"pending": "{name} is uploading",
|
||||||
|
"done": "{name} is uploaded",
|
||||||
|
"failed": "{name}: {reason}"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"scope": "View as",
|
||||||
|
"untitled": "Untitled",
|
||||||
|
"empty": "This entry has no content yet.",
|
||||||
|
"hiddenTitle": "Invisible for this audience",
|
||||||
|
"hidden": "As {scope} this entry is not visible, it is meant for {audience}."
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"blockersTitle": "This holds publishing back",
|
||||||
|
"title_missing": "The title is missing.",
|
||||||
|
"content_empty": "The entry has no content yet.",
|
||||||
|
"alt_text_missing": "An image has no alt text. Public entries require one.",
|
||||||
|
"tags_missing": "No tag set yet.",
|
||||||
|
"cover_missing": "No cover image set yet."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"forbidden": "Your account lacks the right for this.",
|
||||||
|
"projectUnknown": "This project does not exist.",
|
||||||
|
"typeUnknown": "This post type is no longer available. Pick a different one.",
|
||||||
|
"projectLocked": "The project cannot be changed once the entry exists.",
|
||||||
|
"notFound": "This entry is gone. Reload the page.",
|
||||||
|
"titleMissing": "Enter a title first.",
|
||||||
|
"titleTooLong": "The title is too long.",
|
||||||
|
"teaserTooLong": "The teaser is too long.",
|
||||||
|
"slugInvalid": "The slug consists of lowercase letters, digits and hyphens, at least two characters.",
|
||||||
|
"slugTaken": "This slug already exists in this project.",
|
||||||
|
"blocksInvalid": "A block is incomplete. Check the addresses and the images.",
|
||||||
|
"blocked": "Publishing is not possible yet.",
|
||||||
|
"statusInvalid": "This step is not possible from the current status.",
|
||||||
|
"scheduleMissing": "Pick a date and time.",
|
||||||
|
"schedulePast": "The date has to be in the future.",
|
||||||
|
"uploadFailed": "The upload did not work just now. Try again in a moment.",
|
||||||
|
"fileMissing": "Choose an image file.",
|
||||||
|
"fileTooLarge": "The file is larger than 15 MB. Shrink it and try again.",
|
||||||
|
"unsupportedType": "This format is not accepted. Use JPEG, PNG, WebP, AVIF, GIF or TIFF.",
|
||||||
|
"unreadableImage": "The file cannot be read as an image. Check whether it is complete.",
|
||||||
|
"unknown": "That did not work just now. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"brands": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Brands",
|
||||||
|
"intro": "Brands group projects, by company for example. Every project belongs to exactly one brand.",
|
||||||
|
"create": "Create brand",
|
||||||
|
"createTitle": "New brand",
|
||||||
|
"createIntro": "Name and colour are enough. The slug is suggested from the name.",
|
||||||
|
"editIntro": "Changes take effect right away for all projects of this brand.",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving",
|
||||||
|
"back": "Back to the list",
|
||||||
|
"emptyTitle": "No brand yet",
|
||||||
|
"empty": "There is no brand yet. Create the first one, then you can create projects.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"color": "Colour",
|
||||||
|
"projects": "Projects",
|
||||||
|
"sort": "Sorting",
|
||||||
|
"edit": "Edit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"slug": "Slug",
|
||||||
|
"color": "Colour",
|
||||||
|
"colorPicker": "Pick a colour",
|
||||||
|
"sort": "Sorting"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"slug": "Lower case letters, digits and hyphens.",
|
||||||
|
"color": "As a hex value, for example #2b4a9b.",
|
||||||
|
"sort": "Smaller numbers come first."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postTypes": {
|
||||||
|
"eyebrow": "Admin",
|
||||||
|
"title": "Post types",
|
||||||
|
"intro": "Post types mark what a post is about. Key, label, colour and order are set here.",
|
||||||
|
"create": "Add post type",
|
||||||
|
"createTitle": "New post type",
|
||||||
|
"createIntro": "Label and colour are enough. The key is derived from the German label.",
|
||||||
|
"editIntro": "Changes apply at once to every post of this type.",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving",
|
||||||
|
"back": "Back to the list",
|
||||||
|
"emptyTitle": "No post type yet",
|
||||||
|
"empty": "There is no post type yet. Add the first one, then you can classify posts.",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Retired",
|
||||||
|
"usage": "{count, plural, =0 {no post} one {# post} other {# posts}}",
|
||||||
|
"deleteTitle": "Delete",
|
||||||
|
"delete": "Delete post type",
|
||||||
|
"deleting": "Deleting",
|
||||||
|
"deleteBlocked": "This type is used by {count, plural, one {# post} other {# posts}} and cannot be deleted. Clear the active checkbox instead, then it is no longer offered.",
|
||||||
|
"columns": {
|
||||||
|
"label": "Label",
|
||||||
|
"key": "Key",
|
||||||
|
"color": "Colour",
|
||||||
|
"posts": "Posts",
|
||||||
|
"sort": "Order",
|
||||||
|
"active": "Active",
|
||||||
|
"edit": "Edit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"labelDe": "Label German",
|
||||||
|
"labelEn": "Label English",
|
||||||
|
"key": "Key",
|
||||||
|
"color": "Colour",
|
||||||
|
"colorPicker": "Pick a colour",
|
||||||
|
"sort": "Order",
|
||||||
|
"isActive": "Active"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"key": "Lower case letters, digits and hyphens. This is what the API reports.",
|
||||||
|
"color": "As a hex value, for example #2b4a9b. It marks the type on a post.",
|
||||||
|
"sort": "Smaller numbers come first, in the picker and in the filters.",
|
||||||
|
"isActive": "Only active types are offered while writing.",
|
||||||
|
"activeInUse": "Only active types are offered while writing. Posts that already carry this type keep it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "Users",
|
||||||
|
"intro": "Only people who write need an account. Reading works without one.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Right",
|
||||||
|
"projects": "Projects",
|
||||||
|
"edit": "Open"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Administration",
|
||||||
|
"moderator": "Moderator"
|
||||||
|
},
|
||||||
|
"you": "you",
|
||||||
|
"all": "all",
|
||||||
|
"back": "Back to the list",
|
||||||
|
"saving": "Saving",
|
||||||
|
"emptyTitle": "No account yet",
|
||||||
|
"empty": "There is no account yet. Create one with the command pnpm admin:create.",
|
||||||
|
"newAccountTitle": "New account",
|
||||||
|
"newAccount": "Accounts are created with the command pnpm admin:create. After that you hand out the rights here.",
|
||||||
|
"adminSection": "Administration right",
|
||||||
|
"isAdmin": "This account administers everything and reaches every project.",
|
||||||
|
"isNotAdmin": "This account works only in the projects it has been released for.",
|
||||||
|
"grantAdmin": "Grant administration right",
|
||||||
|
"revokeAdmin": "Revoke administration right",
|
||||||
|
"selfHint": "The administration right cannot be changed on your own account.",
|
||||||
|
"rolesSection": "Projects",
|
||||||
|
"assignedMeta": "{count, plural, =0 {no project} one {# project} other {# projects}}",
|
||||||
|
"adminHasAll": "This account holds the administration right and reaches every project anyway. The releases below apply once the administration right is revoked.",
|
||||||
|
"grantRole": "Release",
|
||||||
|
"revokeRole": "Withdraw",
|
||||||
|
"implicit": "through administration right",
|
||||||
|
"rolesHint": "A moderator creates and publishes entries and images of their projects.",
|
||||||
|
"noProjectsTitle": "No project yet",
|
||||||
|
"noProjects": "There is no project you could release yet.",
|
||||||
|
"toProjects": "Create project",
|
||||||
|
"accountSection": "Account",
|
||||||
|
"accountHint": "Name, email and password are set by the command pnpm admin:create."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"eyebrow": "Administration",
|
||||||
|
"title": "API access",
|
||||||
|
"intro": "Applications fetch entries through an access. Read access delivers published entries of its audience, write access creates drafts.",
|
||||||
|
"listTitle": "Access",
|
||||||
|
"meta": "{count, plural, =0 {no access} one {# access} other {# accesses}}",
|
||||||
|
"emptyTitle": "No access yet",
|
||||||
|
"empty": "There is no access yet. Create one below so an application can fetch entries.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"mode": "Mode",
|
||||||
|
"scope": "Audience",
|
||||||
|
"project": "Project",
|
||||||
|
"lastUsed": "Last used",
|
||||||
|
"state": "State",
|
||||||
|
"action": "Action"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"read": "Read",
|
||||||
|
"write": "Write"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"internal": "Internal",
|
||||||
|
"customer": "Customer",
|
||||||
|
"public": "Public"
|
||||||
|
},
|
||||||
|
"allProjects": "All projects",
|
||||||
|
"unknownProject": "Unknown",
|
||||||
|
"never": "never",
|
||||||
|
"activeState": "Active",
|
||||||
|
"revoked": "Revoked",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revoking": "Revoking",
|
||||||
|
"createTitle": "Create access",
|
||||||
|
"createIntro": "The token is shown once and stored as a hash only. Whoever loses it creates a new access and revokes the old one.",
|
||||||
|
"create": "Create access",
|
||||||
|
"creating": "Creating",
|
||||||
|
"tokenTitle": "Token, visible now only",
|
||||||
|
"tokenHint": "Copy the token now and put it into the application. It cannot be shown again later.",
|
||||||
|
"copy": "Copy token",
|
||||||
|
"copied": "Copied",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"mode": "Mode",
|
||||||
|
"scope": "Audience",
|
||||||
|
"project": "Project"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"name": "How you recognise this access later, for example Trakk Web.",
|
||||||
|
"mode": "Read fetches entries. Write creates drafts and uploads images.",
|
||||||
|
"scope": "Decides which entries the access sees. Customer never gets internal entries.",
|
||||||
|
"project": "Without a binding the access covers all projects."
|
||||||
|
},
|
||||||
|
"publishTitle": "Publishing",
|
||||||
|
"publishHint": "No access may publish. That stays bound to accounts."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"type": "Type",
|
||||||
|
"allTypes": "All types"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"eyebrow": "Images",
|
||||||
|
"title": "Media library",
|
||||||
|
"intro": "One image stock per project. Uploading creates WebP renditions in several widths automatically.",
|
||||||
|
"back": "All projects",
|
||||||
|
"library": "Stock",
|
||||||
|
"projectIntro": "Upload images and maintain alt text and caption. Without alt text a public entry cannot be published.",
|
||||||
|
"projectMeta": "{count, plural, =0 {no image} one {# image} other {# images}}",
|
||||||
|
"emptyProjectsTitle": "No project yet",
|
||||||
|
"emptyProjects": "No project has been released for you yet, or none has been created.",
|
||||||
|
"emptyLibraryTitle": "No image yet",
|
||||||
|
"emptyLibrary": "This project holds no image yet. Upload the first one with the field above.",
|
||||||
|
"upload": {
|
||||||
|
"title": "Upload image",
|
||||||
|
"hint": "JPEG, PNG, WebP, AVIF, GIF or TIFF. The original is kept, the renditions are made from it.",
|
||||||
|
"file": "File",
|
||||||
|
"submit": "Upload",
|
||||||
|
"pending": "Processing",
|
||||||
|
"limit": "15 MB at most"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"alt": "Alt text",
|
||||||
|
"altHint": "Mandatory for public entries, a recommendation otherwise.",
|
||||||
|
"altPlaceholder": "What can be seen in the image?",
|
||||||
|
"caption": "Caption"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"submit": "Save",
|
||||||
|
"pending": "Saving"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {no rendition} one {# rendition} other {# renditions}}",
|
||||||
|
"altMissing": "No alt text. It is mandatory for public entries.",
|
||||||
|
"variantError": "The renditions could not be created. The original is stored and usable."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"uploaded": "The image is uploaded and the renditions are created.",
|
||||||
|
"saved": "Alt text and caption are saved.",
|
||||||
|
"fileMissing": "Pick an image file.",
|
||||||
|
"fileTooLarge": "The file is larger than 15 MB. Shrink it and try again.",
|
||||||
|
"unsupportedType": "This format is not accepted. Use JPEG, PNG, WebP, AVIF, GIF or TIFF.",
|
||||||
|
"unreadableImage": "The file cannot be read as an image. Check whether it is complete.",
|
||||||
|
"projectUnknown": "This project does not exist.",
|
||||||
|
"mediaUnknown": "This image no longer exists.",
|
||||||
|
"uploadFailed": "Uploading did not work just now. Try again in a moment."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"magicLink": {
|
||||||
|
"subject": "Your sign-in link for Logbuch",
|
||||||
|
"greeting": "Hello,",
|
||||||
|
"body": "here is your sign-in link for Logbuch. It is valid for {minutes} minutes and can be used once.",
|
||||||
|
"ignore": "If you did not request a link, ignore this message. Nothing happens without the link.",
|
||||||
|
"signature": "Logbuch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
typedRoutes: true,
|
||||||
|
experimental: {
|
||||||
|
useTypeScriptCli: true,
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '16mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
formats: ['image/webp'],
|
||||||
|
deviceSizes: [480, 640, 960, 1200, 1600, 1920],
|
||||||
|
imageSizes: [96, 160, 240, 320],
|
||||||
|
minimumCacheTTL: 31536000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
||||||
|
|
||||||
|
export default withNextIntl(config)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "logbuch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.30.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 4700",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --port 4700",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
|
"admin:create": "tsx scripts/create-admin.ts",
|
||||||
|
"publish:due": "tsx scripts/publish-due.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-auth": "1.6.25",
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"next": "16.2.12",
|
||||||
|
"next-intl": "4.13.4",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"overlayscrollbars": "2.16.0",
|
||||||
|
"overlayscrollbars-react": "0.5.6",
|
||||||
|
"pg": "8.22.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"react-icons": "5.7.0",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"zod": "4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "0.31.10",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4033
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { '@tailwindcss/postcss': {} },
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { pool } from '~/data/db'
|
||||||
|
import { auth, minPasswordLength } from '~/lib/auth'
|
||||||
|
|
||||||
|
type Outcome = 'created' | 'updated'
|
||||||
|
|
||||||
|
function readEnv(name: string): string {
|
||||||
|
const value = process.env[name]?.trim()
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} fehlt. Aufruf: ${name}=... pnpm admin:create`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdmin(email: string, password: string, name: string): Promise<Outcome> {
|
||||||
|
if (password.length < minPasswordLength) {
|
||||||
|
throw new Error(`ADMIN_PASSWORD ist zu kurz. Es braucht mindestens ${minPasswordLength} Zeichen.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await auth.$context
|
||||||
|
const hashed = await context.password.hash(password)
|
||||||
|
const existing = await context.internalAdapter.findUserByEmail(email, { includeAccounts: true })
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const user = await context.internalAdapter.createUser({ email, name, emailVerified: true, isAdmin: true })
|
||||||
|
|
||||||
|
await context.internalAdapter.createAccount({
|
||||||
|
userId: user.id,
|
||||||
|
providerId: 'credential',
|
||||||
|
accountId: user.id,
|
||||||
|
password: hashed,
|
||||||
|
})
|
||||||
|
|
||||||
|
return 'created'
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = existing.accounts.find(account => account.providerId === 'credential')
|
||||||
|
|
||||||
|
if (credential) {
|
||||||
|
await context.internalAdapter.updatePassword(existing.user.id, hashed)
|
||||||
|
} else {
|
||||||
|
await context.internalAdapter.createAccount({
|
||||||
|
userId: existing.user.id,
|
||||||
|
providerId: 'credential',
|
||||||
|
accountId: existing.user.id,
|
||||||
|
password: hashed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.internalAdapter.updateUser(existing.user.id, { isAdmin: true })
|
||||||
|
|
||||||
|
return 'updated'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const email = readEnv('ADMIN_EMAIL')
|
||||||
|
const password = readEnv('ADMIN_PASSWORD')
|
||||||
|
const name = process.env.ADMIN_NAME?.trim() || email.split('@')[0] || email
|
||||||
|
|
||||||
|
const outcome = await createAdmin(email, password, name)
|
||||||
|
|
||||||
|
if (outcome === 'created') {
|
||||||
|
console.log(`Admin angelegt: ${email}. Anmeldung ab sofort unter /login möglich.`)
|
||||||
|
} else {
|
||||||
|
console.log(`Admin aktualisiert: ${email}. Passwort neu gesetzt, Verwaltungsrecht bestätigt.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => pool.end())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { pool } from '~/data/db'
|
||||||
|
import { publishDueEntries } from '~/lib/publish-due'
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { published } = await publishDueEntries()
|
||||||
|
|
||||||
|
if (published.length === 0) {
|
||||||
|
console.log('Kein terminierter Beitrag ist fällig.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of published) {
|
||||||
|
console.log(`Veröffentlicht: ${entry.number} ${entry.slug} (${entry.title})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${published.length} Beiträge veröffentlicht.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => pool.end())
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { findMedia } from '~/data/repositories/media'
|
||||||
|
import { renderPng, renderVariant } from '~/lib/media-images'
|
||||||
|
import { uploadMedia } from '~/lib/media-upload'
|
||||||
|
import { getStorage, objectPath } from '~/lib/storage'
|
||||||
|
|
||||||
|
export const sampleWidth = 2000
|
||||||
|
|
||||||
|
export const sampleHeight = 1125
|
||||||
|
|
||||||
|
export type SampleImage = {
|
||||||
|
key: string
|
||||||
|
id: string
|
||||||
|
project: string
|
||||||
|
filename: string
|
||||||
|
alt: string
|
||||||
|
caption: string
|
||||||
|
svg: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function rng(seed: number): () => number {
|
||||||
|
let state = seed % 4294967296
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) % 4294967296
|
||||||
|
|
||||||
|
return state / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(body: string, tint: string): string {
|
||||||
|
return [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${sampleWidth}" height="${sampleHeight}" viewBox="0 0 ${sampleWidth} ${sampleHeight}">`,
|
||||||
|
'<rect width="100%" height="100%" fill="#f2f1ec"/>',
|
||||||
|
`<rect x="0" y="0" width="${sampleWidth}" height="14" fill="${tint}"/>`,
|
||||||
|
'<rect x="60" y="74" width="1880" height="991" fill="#fbfaf6" stroke="#dedcd3" stroke-width="2"/>',
|
||||||
|
body,
|
||||||
|
'</svg>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleGrid(): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
for (let y = 150; y < 1040; y += 74) {
|
||||||
|
lines.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleEngine(): string {
|
||||||
|
const tint = '#2e7d5b'
|
||||||
|
const parts: string[] = [ruleGrid()]
|
||||||
|
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const y = 240 + index * 250
|
||||||
|
|
||||||
|
parts.push(`<rect x="180" y="${y}" width="520" height="150" rx="6" fill="#f2f1ec" stroke="#8a8f95" stroke-width="2"/>`)
|
||||||
|
parts.push(`<rect x="220" y="${y + 42}" width="${360 - index * 60}" height="16" rx="8" fill="#8a8f95"/>`)
|
||||||
|
parts.push(`<rect x="220" y="${y + 84}" width="${240 - index * 40}" height="12" rx="6" fill="#dedcd3"/>`)
|
||||||
|
|
||||||
|
parts.push(`<rect x="1300" y="${y}" width="520" height="150" rx="6" fill="${tint}" opacity="${round(0.9 - index * 0.2)}"/>`)
|
||||||
|
parts.push(`<rect x="1340" y="${y + 42}" width="${340 - index * 50}" height="16" rx="8" fill="#fbfaf6" opacity="0.85"/>`)
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`<path d="M700 ${y + 75} C 900 ${y + 75}, 1100 ${y + 75}, 1300 ${y + 75}" fill="none" stroke="${tint}" stroke-width="4"/>`,
|
||||||
|
)
|
||||||
|
parts.push(`<circle cx="1300" cy="${y + 75}" r="10" fill="${tint}"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame(parts.join(''), tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnMenu(): string {
|
||||||
|
const tint = '#2b4a9b'
|
||||||
|
const parts: string[] = []
|
||||||
|
const columns = [180, 560, 940, 1320, 1620]
|
||||||
|
const widths = [340, 340, 340, 260, 220]
|
||||||
|
|
||||||
|
parts.push('<rect x="120" y="150" width="1760" height="70" fill="#f2f1ec"/>')
|
||||||
|
|
||||||
|
columns.forEach((x, column) => {
|
||||||
|
const width = widths[column] ?? 300
|
||||||
|
|
||||||
|
parts.push(`<rect x="${x}" y="176" width="${Math.round(width * 0.55)}" height="18" rx="9" fill="#4c5157"/>`)
|
||||||
|
|
||||||
|
for (let row = 0; row < 9; row += 1) {
|
||||||
|
const y = 260 + row * 84
|
||||||
|
const bar = Math.round(width * (0.45 + ((row + column) % 4) * 0.13))
|
||||||
|
const fill = column === 3 ? tint : '#dedcd3'
|
||||||
|
const opacity = column === 3 ? round(0.85 - (row % 4) * 0.15) : 1
|
||||||
|
|
||||||
|
parts.push(`<rect x="${x}" y="${y}" width="${bar}" height="16" rx="8" fill="${fill}" opacity="${opacity}"/>`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let row = 0; row < 9; row += 1) {
|
||||||
|
const y = 244 + row * 84
|
||||||
|
|
||||||
|
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`<rect x="1300" y="228" width="360" height="300" rx="6" fill="#fbfaf6" stroke="${tint}" stroke-width="3"/>`)
|
||||||
|
|
||||||
|
for (let entry = 0; entry < 4; entry += 1) {
|
||||||
|
parts.push(`<rect x="1340" y="${262 + entry * 66}" width="${280 - entry * 30}" height="14" rx="7" fill="#8a8f95"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame(parts.join(''), tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackerMap(): string {
|
||||||
|
const tint = '#b4761a'
|
||||||
|
const parts: string[] = []
|
||||||
|
const random = rng(20260730)
|
||||||
|
|
||||||
|
for (let x = 180; x < 1880; x += 106) {
|
||||||
|
parts.push(`<line x1="${x}" y1="120" x2="${x}" y2="1020" stroke="#f2f1ec" stroke-width="2"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 150; y < 1020; y += 96) {
|
||||||
|
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#f2f1ec" stroke-width="2"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < 220; index += 1) {
|
||||||
|
const x = round(160 + random() * 1700)
|
||||||
|
const y = round(140 + random() * 860)
|
||||||
|
const radius = round(3 + random() * 4)
|
||||||
|
|
||||||
|
parts.push(`<circle cx="${x}" cy="${y}" r="${radius}" fill="#8a8f95" opacity="0.55"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = [
|
||||||
|
{ x: 520, y: 400, size: 96 },
|
||||||
|
{ x: 1180, y: 620, size: 132 },
|
||||||
|
{ x: 1560, y: 320, size: 74 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="${tint}" opacity="0.14"/>`)
|
||||||
|
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="none" stroke="${tint}" stroke-width="3"/>`)
|
||||||
|
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="14" fill="${tint}"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`<path d="M520 400 L1180 620 L1560 320" fill="none" stroke="${tint}" stroke-width="3" stroke-dasharray="14 12"/>`)
|
||||||
|
|
||||||
|
return frame(parts.join(''), tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function slaClock(): string {
|
||||||
|
const tint = '#2e7d5b'
|
||||||
|
const parts: string[] = []
|
||||||
|
const pauses = [
|
||||||
|
{ x: 700, width: 220 },
|
||||||
|
{ x: 1420, width: 220 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pause of pauses) {
|
||||||
|
parts.push(`<rect x="${pause.x}" y="150" width="${pause.width}" height="890" fill="#f2f1ec"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < 7; index += 1) {
|
||||||
|
const y = 250 + index * 110
|
||||||
|
const start = 180 + index * 42
|
||||||
|
const width = 420 + ((index * 7) % 5) * 190
|
||||||
|
|
||||||
|
parts.push(`<line x1="120" y1="${y + 44}" x2="1880" y2="${y + 44}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||||
|
parts.push(`<rect x="${start}" y="${y}" width="${width}" height="26" rx="13" fill="${tint}" opacity="${round(0.85 - index * 0.08)}"/>`)
|
||||||
|
parts.push(`<circle cx="${start}" cy="${y + 13}" r="9" fill="#4c5157"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('<rect x="120" y="150" width="1760" height="60" fill="#f2f1ec"/>')
|
||||||
|
|
||||||
|
for (let tick = 0; tick < 12; tick += 1) {
|
||||||
|
parts.push(`<rect x="${180 + tick * 146}" y="168" width="42" height="12" rx="6" fill="#8a8f95"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame(parts.join(''), tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inbox(): string {
|
||||||
|
const tint = '#6b4ba8'
|
||||||
|
const parts: string[] = []
|
||||||
|
const lanes = [200, 700, 1200]
|
||||||
|
|
||||||
|
lanes.forEach((x, lane) => {
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
const y = 210 + row * 120
|
||||||
|
|
||||||
|
parts.push(`<rect x="${x}" y="${y}" width="420" height="88" rx="6" fill="#f2f1ec" stroke="#dedcd3" stroke-width="2"/>`)
|
||||||
|
parts.push(`<circle cx="${x + 44}" cy="${y + 44}" r="18" fill="${tint}" opacity="${round(0.75 - lane * 0.18)}"/>`)
|
||||||
|
parts.push(`<rect x="${x + 84}" y="${y + 28}" width="${280 - row * 40}" height="14" rx="7" fill="#8a8f95"/>`)
|
||||||
|
parts.push(`<rect x="${x + 84}" y="${y + 54}" width="${200 - row * 30}" height="10" rx="5" fill="#dedcd3"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`<path d="M${x + 210} 698 C ${x + 210} 800, 1000 800, 1000 880" fill="none" stroke="${tint}" stroke-width="3" opacity="0.6"/>`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
parts.push(`<rect x="640" y="880" width="720" height="120" rx="8" fill="${tint}"/>`)
|
||||||
|
parts.push('<rect x="700" y="920" width="420" height="18" rx="9" fill="#fbfaf6" opacity="0.9"/>')
|
||||||
|
parts.push('<rect x="700" y="954" width="300" height="14" rx="7" fill="#fbfaf6" opacity="0.6"/>')
|
||||||
|
|
||||||
|
return frame(parts.join(''), tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sampleImages: SampleImage[] = [
|
||||||
|
{
|
||||||
|
key: 'regel-engine',
|
||||||
|
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00001',
|
||||||
|
project: 'trakk',
|
||||||
|
filename: 'regel-engine-bedingung-aktion.png',
|
||||||
|
alt: 'Schema der Regel-Engine: links drei Bedingungen, rechts drei Aktionen, jeweils mit einer Linie verbunden',
|
||||||
|
caption: 'Jede Bedingung zeigt auf genau eine Aktion.',
|
||||||
|
svg: ruleEngine,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spaltenmenue',
|
||||||
|
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00002',
|
||||||
|
project: 'mta360',
|
||||||
|
filename: 'spaltenmenue-rechtsklick.png',
|
||||||
|
alt: 'Tabelle mit fünf Spalten, über der vierten Spalte ist ein Menü mit vier Einträgen geöffnet',
|
||||||
|
caption: 'Das Spaltenmenü öffnet sich über der Spalte, die es betrifft.',
|
||||||
|
svg: columnMenu,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tracker-karte',
|
||||||
|
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00003',
|
||||||
|
project: 'mta-telematik',
|
||||||
|
filename: 'tracker-karte-cluster.png',
|
||||||
|
alt: 'Kartenausschnitt mit vielen kleinen Punkten und drei hervorgehobenen Ballungen, die mit einer gestrichelten Linie verbunden sind',
|
||||||
|
caption: 'Dicht beieinander liegende Tracker werden zu Ballungen zusammengefasst.',
|
||||||
|
svg: trackerMap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sla-uhr',
|
||||||
|
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00004',
|
||||||
|
project: 'trakk',
|
||||||
|
filename: 'sla-uhr-pausiert.png',
|
||||||
|
alt: 'Zeitleiste mit sieben Balken, zwei helle senkrechte Bänder markieren die Zeiten, in denen die Uhr steht',
|
||||||
|
caption: 'Die hellen Bänder sind Wochenenden und Feiertage, dort läuft die Uhr nicht.',
|
||||||
|
svg: slaClock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eingang',
|
||||||
|
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00005',
|
||||||
|
project: 'orbit',
|
||||||
|
filename: 'kommentare-ein-eingang.png',
|
||||||
|
alt: 'Drei Spalten mit Kommentaren laufen über geschwungene Linien in einen gemeinsamen Eingang unten zusammen',
|
||||||
|
caption: 'Drei Kanäle, ein Eingang.',
|
||||||
|
svg: inbox,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function ensureSampleMedia(projectIds: Map<string, string>): Promise<Map<string, string>> {
|
||||||
|
const storage = getStorage()
|
||||||
|
const ids = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const sample of sampleImages) {
|
||||||
|
const projectId = projectIds.get(sample.project)
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.set(sample.key, sample.id)
|
||||||
|
|
||||||
|
const existing = await findMedia(sample.id)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (!(await storage.exists(existing.path))) {
|
||||||
|
const body = await renderPng(sample.svg())
|
||||||
|
|
||||||
|
await storage.put(existing.path, body, 'image/png')
|
||||||
|
|
||||||
|
for (const variant of existing.variants) {
|
||||||
|
const rendered = await renderVariant(body, variant.width)
|
||||||
|
|
||||||
|
await storage.put(variant.path, rendered.body, 'image/webp')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = objectPath(projectId, sample.id, 'original.png')
|
||||||
|
const stored = await storage.read(path)
|
||||||
|
const body = stored ? stored.body : await renderPng(sample.svg())
|
||||||
|
|
||||||
|
await uploadMedia({
|
||||||
|
id: sample.id,
|
||||||
|
projectId,
|
||||||
|
filename: sample.filename,
|
||||||
|
body,
|
||||||
|
alt: sample.alt,
|
||||||
|
caption: sample.caption,
|
||||||
|
reuse: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db, pool } from '~/data/db'
|
||||||
|
import { apiClients, blocks, brands, postTypes, posts, projects } from '~/data/schema'
|
||||||
|
import { defaultPostTypes } from '~/domain/post-type'
|
||||||
|
import { hashToken } from '~/lib/api-auth'
|
||||||
|
import { ensureSampleMedia } from './sample-images'
|
||||||
|
|
||||||
|
const brandRows = [
|
||||||
|
{ slug: 'nyo', name: 'NYO', color: '#2b4a9b', sort: 1 },
|
||||||
|
{ slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const projectRows = [
|
||||||
|
{ brand: 'pocket-rocket', slug: 'trakk', name: 'Trakk', code: 'TRK', color: '#2e7d5b', sort: 1 },
|
||||||
|
{ brand: 'nyo', slug: 'mta360', name: 'MTA360', code: 'MTA', color: '#2b4a9b', sort: 2 },
|
||||||
|
{ brand: 'nyo', slug: 'mta-telematik', name: 'MTA Telematik', code: 'TEL', color: '#b4761a', sort: 3 },
|
||||||
|
{ brand: 'pocket-rocket', slug: 'orbit', name: 'Orbit', code: 'ORB', color: '#6b4ba8', sort: 4 },
|
||||||
|
{ brand: 'nyo', slug: 'pulsar', name: 'Pulsar', code: 'PLS', color: '#a33b6a', sort: 5 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const postRows = [
|
||||||
|
{
|
||||||
|
project: 'trakk', number: 142, slug: 'regel-engine-bedingung-trifft-aktion',
|
||||||
|
title: 'Regel-Engine: Bedingung trifft Aktion', type: 'feature' as const, audience: 'customer' as const,
|
||||||
|
teaser: 'Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken. Läuft über den Event-Bus im Worker, ohne Cronjobs.',
|
||||||
|
publishAt: new Date('2026-07-30T07:14:00Z'), author: 'Paul', media: 'regel-engine',
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Bisher war eine Regel im Grunde ein Cronjob mit einer Datenbankabfrage darin. Wer sie ändern wollte, brauchte einen Entwickler und ein Deployment. Das ist vorbei: eine Regel besteht jetzt aus einer Bedingung und einer Aktion, beides im Ticketsystem selbst zusammengeklickt.' } },
|
||||||
|
{ type: 'text', data: { text: 'Bedingungen greifen auf alles zu, was ein Ticket ausmacht: Status, Priorität, Kunde, Kategorie, Zeit seit der letzten Antwort. Aktionen setzen den Status, weisen zu, hängen ein Schlagwort an oder schicken eine Nachricht. Mehrere Aktionen an einer Bedingung sind erlaubt und laufen in der Reihenfolge, in der sie stehen.' } },
|
||||||
|
{ type: 'callout', data: { text: 'Regeln greifen ab sofort auch rückwirkend auf offene Tickets. Wer das nicht will, setzt beim Anlegen den Haken für neue Tickets.', tone: 'info' } },
|
||||||
|
{ type: 'text', data: { text: 'Technisch hängt das Ganze am Event-Bus im Worker. Jede Zustandsänderung an einem Ticket erzeugt ein Ereignis, die Regel-Engine hört mit und prüft nur die Regeln, die für dieses Ereignis überhaupt in Frage kommen. Damit bleibt die Last berechenbar, auch wenn ein Kunde hundert Regeln anlegt.' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'mta360', number: 141, slug: 'spalten-per-rechtsklick-verwalten',
|
||||||
|
title: 'Spalten per Rechtsklick verwalten', type: 'improvement' as const, audience: 'customer' as const,
|
||||||
|
teaser: 'Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen. Wer das nicht mag, schaltet es in den Einstellungen ab.',
|
||||||
|
publishAt: new Date('2026-07-29T09:00:00Z'), author: 'Matthias', media: 'spaltenmenue',
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Das Spaltenmenü saß bisher hinter einem Zahnrad über der Tabelle, drei Klicks von der Spalte entfernt, die man eigentlich meinte. Jetzt sitzt es dort, wo man ohnehin hinschaut: auf der Kopfzeile selbst.' } },
|
||||||
|
{ type: 'text', data: { text: 'Im Menü liegen Sortierung, Anpinnen nach links oder rechts, Ausblenden und Breite zurücksetzen. Die Auswahl gilt sofort und wird in der Ansicht gespeichert, nicht im Browser. Wer dieselbe Ansicht auf einem anderen Rechner öffnet, findet seine Spalten wieder.' } },
|
||||||
|
{ type: 'callout', data: { text: 'Wer lieber ohne Rechtsklick arbeitet, schaltet das Menü in den Einstellungen unter Datentabelle ab. Der Weg über die Kopfzeilen-Schaltfläche bleibt bestehen.', tone: 'info' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'mta-telematik', number: 140, slug: 'zehntausend-tracker-auf-einer-karte',
|
||||||
|
title: '10.000 Tracker auf einer Karte', type: 'feature' as const, audience: 'customer' as const,
|
||||||
|
teaser: 'Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft. Die Karte bleibt bei voller Flotte flüssig.',
|
||||||
|
publishAt: new Date('2026-07-28T10:30:00Z'), author: 'Paul', media: 'tracker-karte',
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Zehntausend Fahrzeuge gleichzeitig auf eine Karte zu legen scheiterte bisher an zwei Stellen: die Abfrage holte jede Position einzeln, und der Browser versuchte, zehntausend Marker zu zeichnen. Beides ist gelöst.' } },
|
||||||
|
{ type: 'text', data: { text: 'Die Positionen kommen jetzt gebündelt aus TimescaleDB, vorverdichtet auf die Zoomstufe, die tatsächlich sichtbar ist. Was nah beieinander liegt, wird serverseitig zu einer Gruppe zusammengefasst und erst beim Hineinzoomen aufgelöst.' } },
|
||||||
|
{ type: 'text', data: { text: 'Geofences werden ebenfalls serverseitig geprüft, über PostGIS. Ein Fahrzeug, das eine Zone verlässt, erzeugt ein Ereignis, ohne dass ein Browser offen sein muss. Das war vorher Aufgabe des Frontends und funktionierte nur, solange jemand hinschaute.' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'trakk', number: 139, slug: 'sla-uhr-zaehlt-feiertage-nicht-mehr-mit',
|
||||||
|
title: 'SLA-Uhr zählt Feiertage nicht mehr mit', type: 'fix' as const, audience: 'customer' as const,
|
||||||
|
teaser: 'Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus. Der Kalender pro Kunde gilt jetzt auch für die Uhr.',
|
||||||
|
publishAt: new Date('2026-07-25T08:00:00Z'), author: 'Matthias', media: 'sla-uhr',
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Ein Ticket, das freitags um 17 Uhr aufschlug, war montags früh eskaliert, obwohl niemand hätte antworten können. Grund war eine SLA-Uhr, die schlicht durchlief.' } },
|
||||||
|
{ type: 'text', data: { text: 'Die Uhr pausiert jetzt außerhalb der Geschäftszeiten und an den Feiertagen, die am Kunden hinterlegt sind. Läuft ein Ticket über ein Wochenende, steht die Uhr still und läuft am Montag zur ersten Geschäftsstunde weiter.' } },
|
||||||
|
{ type: 'callout', data: { text: 'Bereits laufende Fristen wurden einmalig neu berechnet. Es kann sein, dass ein Ticket dadurch aus der Eskalation gefallen ist.', tone: 'caution' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'mta360', number: 138, slug: 'lokale-tabellen-konfigurationen-entfallen',
|
||||||
|
title: 'Lokale Tabellen-Konfigurationen werden nicht mehr gelesen', type: 'breaking' as const, audience: 'customer' as const,
|
||||||
|
teaser: 'Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, sieht die Standardansicht und legt seine Ansicht einmal neu an.',
|
||||||
|
publishAt: new Date('2026-07-23T12:00:00Z'), author: 'Matthias', media: undefined,
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Tabellenansichten lagen historisch an zwei Orten: in der Datenbank und zusätzlich im Browser. Wer an zwei Rechnern arbeitete, hatte zwei Wahrheiten, und welche gewann, war schwer vorherzusagen.' } },
|
||||||
|
{ type: 'text', data: { text: 'Ab dieser Version wird ausschließlich gelesen, was in der Datenbank steht. Die lokale Kopie wird beim ersten Start verworfen.' } },
|
||||||
|
{ type: 'callout', data: { text: 'Wer eine Ansicht nur lokal gepflegt hatte, sieht einmalig die Standardansicht und legt seine Ansicht neu an. Danach gilt sie auf allen Geräten.', tone: 'caution' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'orbit', number: 137, slug: 'ein-eingang-fuer-alle-kommentare',
|
||||||
|
title: 'Ein Eingang für alle Kommentare', type: 'info' as const, audience: 'internal' as const,
|
||||||
|
teaser: 'Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.',
|
||||||
|
publishAt: new Date('2026-07-21T15:45:00Z'), author: 'Paul', media: 'eingang',
|
||||||
|
body: [
|
||||||
|
{ type: 'text', data: { text: 'Kommentare kamen bisher dort an, wo sie geschrieben wurden: bei Instagram, bei LinkedIn, bei Facebook. Wer sie beantworten wollte, brauchte drei offene Fenster und ein gutes Gedächtnis.' } },
|
||||||
|
{ type: 'text', data: { text: 'Jetzt laufen alle Kommentare in einen Eingang. Antworten gehen von dort auf demselben Weg zurück, auf dem sie gekommen sind. Wer antwortet und wann, steht am Kommentar.' } },
|
||||||
|
{ type: 'text', data: { text: 'Der Eingang ist bewusst schlicht gehalten: ungelesen zuerst, danach nach Alter. Zuweisen und Erledigen gibt es, mehr nicht. Alles Weitere kommt erst, wenn wir wissen, ob es fehlt.' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function seed(): Promise<void> {
|
||||||
|
const brandIds = new Map<string, string>()
|
||||||
|
const projectIds = new Map<string, string>()
|
||||||
|
for (const row of defaultPostTypes) {
|
||||||
|
await db.insert(postTypes).values(row).onConflictDoNothing({ target: postTypes.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRows = await db.select({ id: postTypes.id, key: postTypes.key }).from(postTypes)
|
||||||
|
const typeIds = new Map(typeRows.map(row => [row.key, row.id]))
|
||||||
|
|
||||||
|
for (const row of brandRows) {
|
||||||
|
const [brand] = await db.insert(brands).values(row)
|
||||||
|
.onConflictDoUpdate({ target: brands.slug, set: { name: row.name, color: row.color, sort: row.sort } })
|
||||||
|
.returning()
|
||||||
|
brandIds.set(row.slug, brand!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of projectRows) {
|
||||||
|
const [project] = await db.insert(projects)
|
||||||
|
.values({ brandId: brandIds.get(row.brand)!, slug: row.slug, name: row.name, code: row.code, color: row.color, sort: row.sort })
|
||||||
|
.onConflictDoUpdate({ target: projects.slug, set: { name: row.name, code: row.code, color: row.color, sort: row.sort } })
|
||||||
|
.returning()
|
||||||
|
projectIds.set(row.slug, project!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaIds = await ensureSampleMedia(projectIds)
|
||||||
|
|
||||||
|
for (const row of postRows) {
|
||||||
|
const coverMediaId = row.media === undefined ? null : mediaIds.get(row.media) ?? null
|
||||||
|
const [post] = await db.insert(posts)
|
||||||
|
.values({
|
||||||
|
projectId: projectIds.get(row.project)!,
|
||||||
|
number: row.number,
|
||||||
|
slug: row.slug,
|
||||||
|
title: row.title,
|
||||||
|
teaser: row.teaser,
|
||||||
|
typeId: typeIds.get(row.type)!,
|
||||||
|
audience: row.audience,
|
||||||
|
status: 'published',
|
||||||
|
publishAt: row.publishAt,
|
||||||
|
authorName: row.author,
|
||||||
|
coverMediaId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [posts.projectId, posts.slug],
|
||||||
|
set: { title: row.title, teaser: row.teaser, typeId: typeIds.get(row.type)!, coverMediaId },
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
await db.delete(blocks).where(eq(blocks.postId, post!.id))
|
||||||
|
await db.insert(blocks).values(row.body.map((entry, index) => ({
|
||||||
|
postId: post!.id,
|
||||||
|
sort: index,
|
||||||
|
type: entry.type,
|
||||||
|
data: entry.data,
|
||||||
|
})))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(apiClients).values({
|
||||||
|
name: 'Trakk Leseclient',
|
||||||
|
tokenHash: hashToken('lb_seed_trakk_read'),
|
||||||
|
mode: 'read',
|
||||||
|
scope: 'customer',
|
||||||
|
projectId: projectIds.get('trakk')!,
|
||||||
|
}).onConflictDoNothing()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1]?.endsWith('seed.ts')) {
|
||||||
|
await seed()
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getFormatter, getTranslations } from 'next-intl/server'
|
||||||
|
import { ProjectArchive } from '~/components/archive/ProjectArchive'
|
||||||
|
import {
|
||||||
|
listArchiveMonths,
|
||||||
|
listPostTypeCounts,
|
||||||
|
listPostsForArchive,
|
||||||
|
loadArchiveStats,
|
||||||
|
loadHighestNumber,
|
||||||
|
loadPostCovers,
|
||||||
|
} from '~/data/repositories/archive'
|
||||||
|
import { monthYearFormat } from '~/lib/dates'
|
||||||
|
import { findProjectView } from '~/lib/project-view'
|
||||||
|
import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
const coverCount = 3
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ project: string, entry: string, month: string }>
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const resolved = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const year = readYear(resolved.entry)
|
||||||
|
const month = readMonth(resolved.month)
|
||||||
|
|
||||||
|
if (year === undefined || month === undefined) {
|
||||||
|
const t = await getTranslations('notFound')
|
||||||
|
return { title: `${app('name')} - ${t('title')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = await getFormatter()
|
||||||
|
const view = await findProjectView(resolved.project, scope)
|
||||||
|
const period = format.dateTime(new Date(Date.UTC(year, month - 1, 1)), monthYearFormat)
|
||||||
|
|
||||||
|
return { title: view ? `${period} - ${view.project.name} - ${app('name')}` : app('name') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MonthPage({ params, searchParams }: Props) {
|
||||||
|
const resolved = await params
|
||||||
|
const year = readYear(resolved.entry)
|
||||||
|
const month = readMonth(resolved.month)
|
||||||
|
|
||||||
|
if (year === undefined || month === undefined) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await searchParams
|
||||||
|
const page = readPage(query.page)
|
||||||
|
const type = readPostType(query.type)
|
||||||
|
const view = await findProjectView(resolved.project, scope)
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stats, months, list, typeCounts, highest] = await Promise.all([
|
||||||
|
loadArchiveStats({ scope, projectSlug: resolved.project, recentDays }),
|
||||||
|
listArchiveMonths({ scope, projectSlug: resolved.project }),
|
||||||
|
listPostsForArchive({ scope, projectSlug: resolved.project, year, month, type, page, perPage }),
|
||||||
|
listPostTypeCounts({ scope, projectSlug: resolved.project, year, month }),
|
||||||
|
loadHighestNumber({ scope, projectSlug: resolved.project }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectArchive
|
||||||
|
view={view}
|
||||||
|
stats={stats}
|
||||||
|
months={months}
|
||||||
|
list={list}
|
||||||
|
typeCounts={typeCounts}
|
||||||
|
page={page}
|
||||||
|
covers={covers}
|
||||||
|
highest={highest}
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { PostDetail } from '~/components/archive/PostDetail'
|
||||||
|
import { ProjectArchive } from '~/components/archive/ProjectArchive'
|
||||||
|
import {
|
||||||
|
findPostWithBlocks,
|
||||||
|
listArchiveMonths,
|
||||||
|
listPostTypeCounts,
|
||||||
|
listPostsForArchive,
|
||||||
|
loadArchiveStats,
|
||||||
|
loadHighestNumber,
|
||||||
|
loadPostCovers,
|
||||||
|
} from '~/data/repositories/archive'
|
||||||
|
import { findProjectView } from '~/lib/project-view'
|
||||||
|
import { perPage, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
const coverCount = 3
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ project: string, entry: string }>
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { project, entry } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const year = readYear(entry)
|
||||||
|
|
||||||
|
if (year !== undefined) {
|
||||||
|
const view = await findProjectView(project, scope)
|
||||||
|
|
||||||
|
return { title: view ? `${year} - ${view.project.name} - ${app('name')}` : app('name') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project })
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
const t = await getTranslations('notFound')
|
||||||
|
return { title: `${app('name')} - ${t('title')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${detail.post.title} - ${detail.project.name}`,
|
||||||
|
description: detail.post.teaser ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EntryPage({ params, searchParams }: Props) {
|
||||||
|
const { project, entry } = await params
|
||||||
|
const year = readYear(entry)
|
||||||
|
|
||||||
|
if (year === undefined) {
|
||||||
|
const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project })
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PostDetail detail={detail} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await searchParams
|
||||||
|
const page = readPage(query.page)
|
||||||
|
const type = readPostType(query.type)
|
||||||
|
const view = await findProjectView(project, scope)
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stats, months, list, typeCounts, highest] = await Promise.all([
|
||||||
|
loadArchiveStats({ scope, projectSlug: project, recentDays }),
|
||||||
|
listArchiveMonths({ scope, projectSlug: project }),
|
||||||
|
listPostsForArchive({ scope, projectSlug: project, year, type, page, perPage }),
|
||||||
|
listPostTypeCounts({ scope, projectSlug: project, year }),
|
||||||
|
loadHighestNumber({ scope, projectSlug: project }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectArchive
|
||||||
|
view={view}
|
||||||
|
stats={stats}
|
||||||
|
months={months}
|
||||||
|
list={list}
|
||||||
|
typeCounts={typeCounts}
|
||||||
|
page={page}
|
||||||
|
covers={covers}
|
||||||
|
highest={highest}
|
||||||
|
year={year}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { ProjectArchive } from '~/components/archive/ProjectArchive'
|
||||||
|
import {
|
||||||
|
listArchiveMonths,
|
||||||
|
listPostTypeCounts,
|
||||||
|
listPostsForArchive,
|
||||||
|
loadArchiveStats,
|
||||||
|
loadHighestNumber,
|
||||||
|
loadPostCovers,
|
||||||
|
} from '~/data/repositories/archive'
|
||||||
|
import { findProjectView } from '~/lib/project-view'
|
||||||
|
import { perPage, readPage, readPostType, recentDays, type SearchParams } from '~/lib/query'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
const coverCount = 3
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ project: string }>
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { project } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const view = await findProjectView(project, scope)
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
const t = await getTranslations('notFound')
|
||||||
|
return { title: `${app('name')} - ${t('title')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${view.project.name} - ${app('name')}`,
|
||||||
|
description: view.project.description ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectPage({ params, searchParams }: Props) {
|
||||||
|
const { project } = await params
|
||||||
|
const query = await searchParams
|
||||||
|
const page = readPage(query.page)
|
||||||
|
const type = readPostType(query.type)
|
||||||
|
const view = await findProjectView(project, scope)
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stats, months, list, typeCounts, highest] = await Promise.all([
|
||||||
|
loadArchiveStats({ scope, projectSlug: project, recentDays }),
|
||||||
|
listArchiveMonths({ scope, projectSlug: project }),
|
||||||
|
listPostsForArchive({ scope, projectSlug: project, type, page, perPage }),
|
||||||
|
listPostTypeCounts({ scope, projectSlug: project }),
|
||||||
|
loadHighestNumber({ scope, projectSlug: project }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectArchive
|
||||||
|
view={view}
|
||||||
|
stats={stats}
|
||||||
|
months={months}
|
||||||
|
list={list}
|
||||||
|
typeCounts={typeCounts}
|
||||||
|
page={page}
|
||||||
|
covers={covers}
|
||||||
|
highest={highest}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { AppShell } from '~/components/layout/AppShell'
|
||||||
|
import { SiteFooter } from '~/components/layout/SiteFooter'
|
||||||
|
import { SiteNav } from '~/components/layout/SiteNav'
|
||||||
|
import { Wordmark } from '~/components/layout/Wordmark'
|
||||||
|
import { loadHighestNumber } from '~/data/repositories/archive'
|
||||||
|
import { requireSession } from '~/lib/auth-guards'
|
||||||
|
import { currentPath } from '~/lib/request-path'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
export default async function SiteLayout({ children }: { children: ReactNode }) {
|
||||||
|
const viewer = await requireSession(await currentPath())
|
||||||
|
const number = await loadHighestNumber({ scope })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
nav={<SiteNav scope={scope} number={number} viewer={viewer} />}
|
||||||
|
mark={<Wordmark number={number} />}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SiteFooter />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { ArchiveMonths } from '~/components/archive/ArchiveMonths'
|
||||||
|
import { EmptyNotice } from '~/components/archive/EmptyNotice'
|
||||||
|
import { EntryList } from '~/components/archive/EntryList'
|
||||||
|
import { FeatureEntry } from '~/components/archive/FeatureEntry'
|
||||||
|
import { FilterBar } from '~/components/archive/FilterBar'
|
||||||
|
import { Pagination } from '~/components/archive/Pagination'
|
||||||
|
import { SideEntries } from '~/components/archive/SideEntries'
|
||||||
|
import { TypeFilter } from '~/components/archive/TypeFilter'
|
||||||
|
import { ViewHeader } from '~/components/archive/ViewHeader'
|
||||||
|
import { Wrap } from '~/components/layout/Wrap'
|
||||||
|
import {
|
||||||
|
listArchiveMonths,
|
||||||
|
listPostTypeCounts,
|
||||||
|
listPostsForArchive,
|
||||||
|
loadArchiveStats,
|
||||||
|
loadPostCovers,
|
||||||
|
} from '~/data/repositories/archive'
|
||||||
|
import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query'
|
||||||
|
import { adminPath, overviewPath } from '~/lib/routes'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
const sideCount = 3
|
||||||
|
|
||||||
|
const sideThreshold = 4
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('overview')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${app('name')} - ${t('title')}`,
|
||||||
|
description: t('intro'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OverviewPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const params = await searchParams
|
||||||
|
const page = readPage(params.page)
|
||||||
|
const year = readYear(params.year)
|
||||||
|
const month = readMonth(params.month)
|
||||||
|
const type = readPostType(params.type)
|
||||||
|
|
||||||
|
const t = await getTranslations('overview')
|
||||||
|
const archive = await getTranslations('archive')
|
||||||
|
const entry = await getTranslations('entry')
|
||||||
|
|
||||||
|
const [stats, months, list, typeCounts] = await Promise.all([
|
||||||
|
loadArchiveStats({ scope, recentDays }),
|
||||||
|
listArchiveMonths({ scope }),
|
||||||
|
listPostsForArchive({ scope, year, month, type, page, perPage }),
|
||||||
|
listPostTypeCounts({ scope, year, month }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const period = year !== undefined || month !== undefined
|
||||||
|
const narrowed = period || type !== undefined
|
||||||
|
const beyond = list.total > 0 && list.items.length === 0
|
||||||
|
const emptyTitle = beyond ? t('emptyPageTitle') : type && list.total === 0 ? t('emptyTypeTitle') : period ? t('emptyFilteredTitle') : t('emptyTitle')
|
||||||
|
const emptyText = beyond ? t('emptyPage') : type && list.total === 0 ? t('emptyType') : period ? t('emptyFiltered') : t('empty')
|
||||||
|
const lead = page === 1 && !narrowed ? list.items[0] : undefined
|
||||||
|
const side = lead && list.items.length >= sideThreshold ? list.items.slice(1, 1 + sideCount) : []
|
||||||
|
const rows = lead ? list.items.slice(1 + side.length) : list.items
|
||||||
|
const updated = page === 1 ? list.items[0]?.publishAt ?? null : stats.to ?? null
|
||||||
|
const typeTotal = typeCounts.reduce((sum, count) => sum + count.postCount, 0)
|
||||||
|
const covers = await loadPostCovers(lead ? [lead, ...side].map(item => item.id) : [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="content">
|
||||||
|
<Wrap>
|
||||||
|
<ViewHeader title={t('title')} intro={t('intro')} updated={updated} total={list.total}>
|
||||||
|
<TypeFilter
|
||||||
|
counts={typeCounts}
|
||||||
|
total={typeTotal}
|
||||||
|
active={type}
|
||||||
|
hrefFor={target => overviewPath({ year, month, type: target })}
|
||||||
|
/>
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
|
<FilterBar year={year} month={month} resetHref={overviewPath({ type })} />
|
||||||
|
|
||||||
|
{list.items.length === 0 ? (
|
||||||
|
<EmptyNotice
|
||||||
|
title={emptyTitle}
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href={beyond ? overviewPath({ year, month, type }) : narrowed ? overviewPath() : adminPath()}
|
||||||
|
className="font-mono text-small"
|
||||||
|
>
|
||||||
|
{beyond ? archive('firstPage') : narrowed ? archive('all') : t('toAdmin')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</EmptyNotice>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-10 pb-14 pt-6">
|
||||||
|
{lead ? (
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
|
<FeatureEntry item={lead} cover={covers.get(lead.id)} newest className="min-w-0 flex-1" />
|
||||||
|
<SideEntries items={side} covers={covers} className="lg:w-72 lg:shrink-0" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<EntryList items={rows} label={lead ? entry('older') : entry('latest')} />
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
total={list.total}
|
||||||
|
hrefFor={target => overviewPath({ page: target, year, month, type })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div id="archive" className="scroll-mt-6">
|
||||||
|
<ArchiveMonths
|
||||||
|
months={months}
|
||||||
|
activeYear={year}
|
||||||
|
activeMonth={month}
|
||||||
|
hrefFor={item => overviewPath({ year: item.year, month: item.month, type })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Wrap>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Form from 'next/form'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbSearch } from 'react-icons/tb'
|
||||||
|
import { EmptyNotice } from '~/components/archive/EmptyNotice'
|
||||||
|
import { EntryList } from '~/components/archive/EntryList'
|
||||||
|
import { Pagination } from '~/components/archive/Pagination'
|
||||||
|
import { ViewHeader } from '~/components/archive/ViewHeader'
|
||||||
|
import { Wrap } from '~/components/layout/Wrap'
|
||||||
|
import { listPostsForArchive, listRecentPosts } from '~/data/repositories/archive'
|
||||||
|
import { perPage, readPage, readText, type SearchParams } from '~/lib/query'
|
||||||
|
import { archivePath, overviewPath, searchPath } from '~/lib/routes'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
const suggestionCount = 5
|
||||||
|
|
||||||
|
export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('search')
|
||||||
|
const query = readText((await searchParams).q)
|
||||||
|
|
||||||
|
return { title: query ? `${t('title')}: ${query} - ${app('name')}` : `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const params = await searchParams
|
||||||
|
const query = readText(params.q)
|
||||||
|
const page = readPage(params.page)
|
||||||
|
|
||||||
|
const t = await getTranslations('search')
|
||||||
|
const archive = await getTranslations('archive')
|
||||||
|
const nav = await getTranslations('nav')
|
||||||
|
const overview = await getTranslations('overview')
|
||||||
|
|
||||||
|
const list = query
|
||||||
|
? await listPostsForArchive({ scope, query, page, perPage })
|
||||||
|
: { items: [], total: 0 }
|
||||||
|
|
||||||
|
const suggestions = list.total === 0 ? await listRecentPosts({ scope, limit: suggestionCount }) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="content">
|
||||||
|
<Wrap>
|
||||||
|
<ViewHeader title={t('title')} intro={t('intro')} total={query ? list.total : undefined}>
|
||||||
|
<Form action={searchPath()} className="flex flex-wrap items-center gap-3">
|
||||||
|
<label htmlFor="q" className="sr-only">
|
||||||
|
{t('label')}
|
||||||
|
</label>
|
||||||
|
<span className="relative flex min-w-0 flex-1 items-center">
|
||||||
|
<TbSearch aria-hidden="true" className="pointer-events-none absolute left-3 size-4 text-ink-3" />
|
||||||
|
<input
|
||||||
|
id="q"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={query ?? ''}
|
||||||
|
placeholder={t('placeholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full min-w-0 border border-rule bg-surface py-2 pl-9 pr-3 font-body text-base text-ink placeholder:text-ink-3 focus:border-signal"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 border border-ink bg-ink px-4 py-2.5 font-mono text-micro font-semibold uppercase tracking-badge text-paper hover:border-signal hover:bg-signal"
|
||||||
|
>
|
||||||
|
{t('submit')}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 pb-14 pt-6">
|
||||||
|
{list.total > 0 && list.items.length === 0 ? (
|
||||||
|
<EmptyNotice
|
||||||
|
title={overview('emptyPageTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={searchPath({ query })} className="font-mono text-small">
|
||||||
|
{archive('firstPage')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{overview('emptyPage')}
|
||||||
|
</EmptyNotice>
|
||||||
|
) : list.total > 0 ? (
|
||||||
|
<>
|
||||||
|
<EntryList
|
||||||
|
items={list.items}
|
||||||
|
label={t('found', { query: query ?? '' })}
|
||||||
|
meta={archive('entries', { count: list.total })}
|
||||||
|
highlight={query}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
total={list.total}
|
||||||
|
hrefFor={target => searchPath({ query, page: target })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EmptyNotice
|
||||||
|
title={query === undefined ? t('promptTitle') : t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
<span className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<Link href={overviewPath()} className="font-mono text-small">
|
||||||
|
{archive('all')}
|
||||||
|
</Link>
|
||||||
|
<Link href={archivePath()} className="font-mono text-small">
|
||||||
|
{nav('archive')}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query === undefined ? t('prompt') : t('empty', { query })}
|
||||||
|
</EmptyNotice>
|
||||||
|
|
||||||
|
<EntryList items={suggestions} label={t('suggestions')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Wrap>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { TbBook, TbDownload } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
|
||||||
|
import { cellClass, headCellClass } from '~/components/admin/styles'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { blockTypes } from '~/domain/blocks'
|
||||||
|
import { listActivePostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
import { adminClientsPath } from '~/lib/admin-routes'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'API - Logbuch' }
|
||||||
|
|
||||||
|
const blockFields: Record<string, string> = {
|
||||||
|
text: 'text',
|
||||||
|
image: 'mediaId',
|
||||||
|
gallery: 'mediaIds',
|
||||||
|
before_after: 'beforeMediaId, afterMediaId',
|
||||||
|
video: 'url, title',
|
||||||
|
quote: 'text, source',
|
||||||
|
code: 'code, language',
|
||||||
|
link: 'url, title, description',
|
||||||
|
callout: 'text, tone',
|
||||||
|
}
|
||||||
|
|
||||||
|
const audiences = [
|
||||||
|
{ key: 'internal', text: 'Nur intern. Umbauten, Zwischenstände, alles über Kunden.' },
|
||||||
|
{ key: 'customer', text: 'Für Kunden des Produkts. Sichtbar für interne und Kunden-Zugänge.' },
|
||||||
|
{ key: 'public', text: 'Öffentlich. Sichtbar für jeden Zugang.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function AdminApiPage() {
|
||||||
|
await requireAdminArea()
|
||||||
|
|
||||||
|
const types = await listActivePostTypes()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-12 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow="Schnittstelle"
|
||||||
|
title="API"
|
||||||
|
intro="Über diese Schnittstelle holen andere Anwendungen ihre Einträge und verfassen neue. Alles hier ist der tatsächliche Stand, nicht die Absicht."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Wofür Logbuch da ist</SectionLabel>
|
||||||
|
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
|
||||||
|
<p className="m-0">
|
||||||
|
Logbuch hält fest, was in den Produkten passiert ist. Alle zwei Wochen entsteht pro Projekt ein
|
||||||
|
Eintrag über das, was fertig geworden ist.
|
||||||
|
</p>
|
||||||
|
<p className="m-0">
|
||||||
|
Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung. Es ist für Kollegen und
|
||||||
|
später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. Wer über
|
||||||
|
die Schnittstelle schreibt, hält sich an den Leitfaden, abrufbar unter
|
||||||
|
{' '}
|
||||||
|
<code className="font-mono text-small">GET /api/v1/style-guide</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Zum Mitnehmen</SectionLabel>
|
||||||
|
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
|
||||||
|
<p className="m-0">
|
||||||
|
Die Beschreibung im OpenAPI-Format lässt sich in Postman, Bruno, Insomnia und ähnliche Werkzeuge
|
||||||
|
einlesen, und ein KI-Assistent kann sie direkt verwenden.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="/api/v1/openapi.json"
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
|
||||||
|
>
|
||||||
|
<TbDownload aria-hidden="true" className="size-4" />
|
||||||
|
openapi.json
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/api/v1/style-guide"
|
||||||
|
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
|
||||||
|
>
|
||||||
|
<TbBook aria-hidden="true" className="size-4" />
|
||||||
|
Redaktionsleitfaden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 text-small">
|
||||||
|
Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als
|
||||||
|
Bearer ein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Anmeldung</SectionLabel>
|
||||||
|
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
|
||||||
|
<p className="m-0">
|
||||||
|
Jede Anfrage trägt ein Token im Kopf:
|
||||||
|
{' '}
|
||||||
|
<code className="font-mono text-small text-ink">Authorization: Bearer <token></code>
|
||||||
|
</p>
|
||||||
|
<p className="m-0">
|
||||||
|
Zugänge legst du unter{' '}
|
||||||
|
<Link href={adminClientsPath}>Zugänge</Link> an. Das Token wird dort einmal im Klartext gezeigt und
|
||||||
|
danach nie wieder, gespeichert ist nur ein Hash.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-2xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={headCellClass}>Eigenschaft</th>
|
||||||
|
<th className={headCellClass}>Bedeutung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className={cellClass}>Modus</td>
|
||||||
|
<td className={cellClass}>read liest, write schreibt zusätzlich</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className={cellClass}>Zielgruppe</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
Bestimmt, welche Einträge der Zugang sieht und wie offen er selbst schreiben darf
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className={cellClass}>Projektbindung</td>
|
||||||
|
<td className={cellClass}>Ist ein Projekt gesetzt, gilt der Zugang ausschließlich dort</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
|
||||||
|
<p className="m-0 max-w-read text-pretty text-ink-2">
|
||||||
|
Ein Zugang mit Schreibrecht kann niemals veröffentlichen. Alles landet als Entwurf, ein Mensch gibt
|
||||||
|
frei.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-5">
|
||||||
|
<SectionLabel>Lesen</SectionLabel>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/projects"
|
||||||
|
auth="read"
|
||||||
|
summary="Die Projekte, die der Zugang sehen darf."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/projects'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/posts"
|
||||||
|
auth="read"
|
||||||
|
summary="Veröffentlichte Einträge, neueste zuerst. Parameter: project, since, type, tag, page, per_page."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n "http://localhost:4700/api/v1/posts?project=trakk&per_page=5"'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/posts/:slug"
|
||||||
|
auth="read"
|
||||||
|
summary="Ein einzelner Eintrag. Nicht sichtbar heißt 404, nicht 403, damit die Antwort seine Existenz nicht verrät."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/regel-engine'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/post-types"
|
||||||
|
auth="read"
|
||||||
|
summary="Die verwaltbaren Beitragsarten mit Schlüssel, Bezeichnung und Farbe. Vor dem Schreiben abfragen statt raten."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/post-types'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/unread"
|
||||||
|
auth="read, projektgebunden"
|
||||||
|
summary="Was ein Nutzer der einbindenden Anwendung noch nicht gelesen hat. Die Kennung ist die des aufrufenden Systems, kein Logbuch-Konto."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n "http://localhost:4700/api/v1/unread?external_user_id=u-42"'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="POST"
|
||||||
|
path="/api/v1/read"
|
||||||
|
auth="read, projektgebunden"
|
||||||
|
summary="Markiert Einträge als gelesen. Unbekannte und fremde Kennungen werden still verworfen."
|
||||||
|
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"external_user_id":"u-42","post_ids":["<id>"]}\' \\\n http://localhost:4700/api/v1/read'}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-5">
|
||||||
|
<SectionLabel>Schreiben</SectionLabel>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="POST"
|
||||||
|
path="/api/v1/posts"
|
||||||
|
auth="write"
|
||||||
|
summary="Legt einen Beitrag an, immer als Entwurf. Mit idempotency_key erzeugt ein wiederholter Aufruf keinen zweiten Beitrag."
|
||||||
|
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{\n "project": "trakk",\n "title": "SLA-Uhr zählt Feiertage nicht mehr mit",\n "teaser": "Reaktionszeiten liefen über Wochenenden weiter.",\n "type": "fix",\n "audience": "customer",\n "idempotency_key": "sla-2026-07-31"\n }\' \\\n http://localhost:4700/api/v1/posts'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="PUT"
|
||||||
|
path="/api/v1/posts/:id/blocks"
|
||||||
|
auth="write"
|
||||||
|
summary="Setzt die Blöcke vollständig, in der übergebenen Reihenfolge. Höchstens 100 Blöcke."
|
||||||
|
example={'curl -s -X PUT -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"blocks":[{"type":"text","data":{"text":"Erster Absatz."}}]}\' \\\n http://localhost:4700/api/v1/posts/<id>/blocks'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="PATCH"
|
||||||
|
path="/api/v1/posts/:id"
|
||||||
|
auth="write"
|
||||||
|
summary="Ändert Titel, Anreißer, Art, Zielgruppe, Slug oder Aufmacherbild. Nur solange nichts veröffentlicht ist, sonst 409."
|
||||||
|
example={'curl -s -X PATCH -H "Authorization: Bearer $TOKEN" \\\n -H "content-type: application/json" \\\n -d \'{"teaser":"Neuer Anreißer."}\' \\\n http://localhost:4700/api/v1/posts/<id>'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="GET"
|
||||||
|
path="/api/v1/posts/:id"
|
||||||
|
auth="write"
|
||||||
|
summary="Liest den Entwurf mit allen Blöcken zurück, zum Prüfen vor der Freigabe."
|
||||||
|
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/<id>'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="DELETE"
|
||||||
|
path="/api/v1/posts/:id"
|
||||||
|
auth="write"
|
||||||
|
summary="Löscht einen Entwurf. Veröffentlichtes lässt sich nicht löschen."
|
||||||
|
example={'curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/<id>'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiEndpoint
|
||||||
|
method="POST"
|
||||||
|
path="/api/v1/media"
|
||||||
|
auth="write"
|
||||||
|
summary="Lädt ein Bild hoch und erzeugt WebP-Varianten. Nur Bildformate, höchstens 15 MB."
|
||||||
|
example={'curl -s -X POST -H "Authorization: Bearer $TOKEN" \\\n -F "file=@screenshot.png" \\\n -F "projectId=<projekt-id>" \\\n -F "alt=Spaltenmenü mit den neuen Einträgen" \\\n http://localhost:4700/api/v1/media'}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Beitragsarten</SectionLabel>
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-2xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={headCellClass}>Schlüssel</th>
|
||||||
|
<th className={headCellClass}>Bezeichnung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{types.map(type => (
|
||||||
|
<tr key={type.id}>
|
||||||
|
<td className={cellClass}><code className="font-mono text-small">{type.key}</code></td>
|
||||||
|
<td className={cellClass}>{type.labelDe}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Zielgruppen</SectionLabel>
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-2xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={headCellClass}>Schlüssel</th>
|
||||||
|
<th className={headCellClass}>Bedeutung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{audiences.map(audience => (
|
||||||
|
<tr key={audience.key}>
|
||||||
|
<td className={cellClass}><code className="font-mono text-small">{audience.key}</code></td>
|
||||||
|
<td className={cellClass}>{audience.text}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
<p className="m-0 max-w-read text-pretty text-ink-2">
|
||||||
|
Ein Zugang darf keine Zielgruppe setzen, die offener ist als seine eigene. Ein Zugang mit customer
|
||||||
|
kann internal und customer schreiben, public nicht.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Blocktypen</SectionLabel>
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-2xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={headCellClass}>Typ</th>
|
||||||
|
<th className={headCellClass}>Felder in data</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{blockTypes.map(type => (
|
||||||
|
<tr key={type}>
|
||||||
|
<td className={cellClass}><code className="font-mono text-small">{type}</code></td>
|
||||||
|
<td className={cellClass}>{blockFields[type] ?? '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>Fehler</SectionLabel>
|
||||||
|
<p className="m-0 max-w-read text-pretty text-ink-2">
|
||||||
|
Fehler kommen einheitlich als Problem-JSON mit type, title, status und detail. 401 fehlendes oder
|
||||||
|
widerrufenes Token, 403 fehlendes Recht, 404 nicht vorhanden oder nicht sichtbar, 409 bereits
|
||||||
|
veröffentlicht, 422 unbekannte Beitragsart, 400 fehlerhafte Eingabe.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { BrandForm } from '~/components/admin/BrandForm'
|
||||||
|
import { findBrandById } from '~/data/repositories/projects'
|
||||||
|
import { isUuid } from '~/lib/admin-forms'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { id } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
const brand = isUuid(id) ? await findBrandById(id) : undefined
|
||||||
|
|
||||||
|
return { title: `${brand?.name ?? t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditBrandPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const brand = isUuid(id) ? await findBrandById(id) : undefined
|
||||||
|
|
||||||
|
if (!brand) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={brand.name} intro={t('editIntro')} />
|
||||||
|
<BrandForm
|
||||||
|
brand={{
|
||||||
|
id: brand.id,
|
||||||
|
name: brand.name,
|
||||||
|
slug: brand.slug,
|
||||||
|
color: brand.color,
|
||||||
|
sort: brand.sort,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { BrandForm } from '~/components/admin/BrandForm'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
|
||||||
|
return { title: `${t('createTitle')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewBrandPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
|
||||||
|
<BrandForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbPlus } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { countProjectsPerBrand, listBrands } from '~/data/repositories/projects'
|
||||||
|
import { adminBrandPath, adminNewBrandPath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminBrandsPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.brands')
|
||||||
|
const [brands, counts] = await Promise.all([listBrands(), countProjectsPerBrand()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('intro')}
|
||||||
|
actions={
|
||||||
|
<Link href={adminNewBrandPath} className={ghostButtonClass}>
|
||||||
|
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{brands.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewBrandPath} className={quietLinkClass}>
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('empty')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.slug')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.projects')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.sort')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>
|
||||||
|
<span className="sr-only">{t('columns.edit')}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{brands.map(brand => (
|
||||||
|
<tr key={brand.id}>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link
|
||||||
|
href={adminBrandPath(brand.id)}
|
||||||
|
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{brand.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro`}>{brand.slug}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(brand.color)}
|
||||||
|
className={`size-3 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-micro uppercase tracking-mark">{brand.color}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(brand.id) ?? 0}</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>{brand.sort}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link href={adminBrandPath(brand.id)} className={quietLinkClass}>
|
||||||
|
{t('columns.edit')}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { ClientForm } from '~/components/admin/ClientForm'
|
||||||
|
import { ClientRevokeButton } from '~/components/admin/ClientRevokeButton'
|
||||||
|
import { cellClass, headCellClass } from '~/components/admin/styles'
|
||||||
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { listClients } from '~/data/repositories/clients'
|
||||||
|
import { listAllProjects } from '~/data/repositories/projects'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.clients')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminClientsPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.clients')
|
||||||
|
const [clients, projects] = await Promise.all([listClients(), listAllProjects()])
|
||||||
|
const projectNames = new Map(projects.map(project => [project.id, project.name]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<span>{t('meta', { count: clients.length })}</span>}>{t('listTitle')}</SectionLabel>
|
||||||
|
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<AdminNotice title={t('emptyTitle')}>{t('empty')}</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-3xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.mode')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.scope')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.project')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.lastUsed')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.state')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>
|
||||||
|
<span className="sr-only">{t('columns.action')}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.map(client => (
|
||||||
|
<tr key={client.id}>
|
||||||
|
<td className={`${cellClass} font-display text-base font-semibold text-ink`}>
|
||||||
|
{client.name}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>{t(`modes.${client.mode}`)}</td>
|
||||||
|
<td className={cellClass}>{t(`scopes.${client.scope}`)}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
{client.projectId === null
|
||||||
|
? t('allProjects')
|
||||||
|
: projectNames.get(client.projectId) ?? t('unknownProject')}
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro`}>
|
||||||
|
{client.lastUsedAt ? <EntryDate date={client.lastUsedAt} /> : t('never')}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
{client.revokedAt ? (
|
||||||
|
<span className="text-signal">{t('revoked')}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-2">{t('activeState')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
{client.revokedAt ? null : <ClientRevokeButton id={client.id} />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 border-t border-rule pt-8">
|
||||||
|
<SectionLabel>{t('createTitle')}</SectionLabel>
|
||||||
|
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{t('createIntro')}</p>
|
||||||
|
<ClientForm projects={projects.map(project => ({ id: project.id, name: project.name }))} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getFormatter, getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { EntryEditor } from '~/components/admin/EntryEditor'
|
||||||
|
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
|
||||||
|
import { listMediaForProject } from '~/data/repositories/media'
|
||||||
|
import { listActivePostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { parseBlocks } from '~/domain/blocks'
|
||||||
|
import { projectCode } from '~/domain/project-code'
|
||||||
|
import { adminEntryPath } from '~/lib/admin-routes'
|
||||||
|
import { requireProjectAccess } from '~/lib/auth-guards'
|
||||||
|
import { toEntryMediaList } from '~/lib/entry-media'
|
||||||
|
import { dateTimeFormat } from '~/lib/dates'
|
||||||
|
import { supportedMimes } from '~/lib/media-images'
|
||||||
|
import { toTypeOption, toTypeOptions, withCurrentType } from '~/lib/post-types'
|
||||||
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
|
type Params = Promise<{ id: string }>
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.entries')
|
||||||
|
const { id } = await params
|
||||||
|
const entry = await findEntry(id)
|
||||||
|
|
||||||
|
return { title: `${entry?.title ?? t('editTitle')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditEntryPage({ params }: { params: Params }) {
|
||||||
|
const { id } = await params
|
||||||
|
const entry = await findEntry(id)
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireProjectAccess(entry.projectId, adminEntryPath(entry.id))
|
||||||
|
|
||||||
|
const format = await getFormatter()
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const [rows, media, types] = await Promise.all([
|
||||||
|
listEntryBlocks(entry.id),
|
||||||
|
listMediaForProject(entry.projectId),
|
||||||
|
listActivePostTypes(),
|
||||||
|
])
|
||||||
|
const parsed = parseBlocks(rows.map(row => ({ type: row.type, data: row.data })))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<EntryEditor
|
||||||
|
projects={[{
|
||||||
|
id: entry.project.id,
|
||||||
|
slug: entry.project.slug,
|
||||||
|
name: entry.project.name,
|
||||||
|
code: projectCode(entry.project),
|
||||||
|
color: entry.project.color,
|
||||||
|
}]}
|
||||||
|
types={withCurrentType(toTypeOptions(types, locale), toTypeOption(entry.type, locale))}
|
||||||
|
media={toEntryMediaList(media)}
|
||||||
|
accept={supportedMimes().join(',')}
|
||||||
|
defaultProjectId={entry.projectId}
|
||||||
|
entry={{
|
||||||
|
id: entry.id,
|
||||||
|
number: entry.number,
|
||||||
|
slug: entry.slug,
|
||||||
|
status: entry.status,
|
||||||
|
publishAt: entry.publishAt ? entry.publishAt.toISOString() : null,
|
||||||
|
updatedLabel: format.dateTime(entry.updatedAt, dateTimeFormat),
|
||||||
|
projectId: entry.projectId,
|
||||||
|
title: entry.title,
|
||||||
|
teaser: entry.teaser ?? '',
|
||||||
|
typeId: entry.typeId,
|
||||||
|
audience: entry.audience,
|
||||||
|
coverMediaId: entry.coverMediaId,
|
||||||
|
blocks: parsed.ok ? parsed.blocks : [],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { EntryEditor } from '~/components/admin/EntryEditor'
|
||||||
|
import { quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { listMediaForProject } from '~/data/repositories/media'
|
||||||
|
import { listActivePostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { listAllProjects } from '~/data/repositories/projects'
|
||||||
|
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
||||||
|
import { projectCode } from '~/domain/project-code'
|
||||||
|
import { adminNewProjectPath, lastProjectCookie } from '~/lib/admin-routes'
|
||||||
|
import { isAdmin } from '~/lib/auth-access'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
import { toEntryMediaList } from '~/lib/entry-media'
|
||||||
|
import { supportedMimes } from '~/lib/media-images'
|
||||||
|
import { toTypeOptions } from '~/lib/post-types'
|
||||||
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.entries')
|
||||||
|
|
||||||
|
return { title: `${t('createTitle')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewEntryPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const viewer = await requireAdminArea()
|
||||||
|
const t = await getTranslations('admin.entries')
|
||||||
|
const params = await searchParams
|
||||||
|
const manages = isAdmin(viewer)
|
||||||
|
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
|
||||||
|
const wanted = typeof params.project === 'string' ? params.project : ''
|
||||||
|
const store = await cookies()
|
||||||
|
const remembered = store.get(lastProjectCookie)?.value ?? ''
|
||||||
|
|
||||||
|
const chosen = projects.find(project => project.slug === wanted)
|
||||||
|
?? projects.find(project => project.id === remembered)
|
||||||
|
?? projects[0]
|
||||||
|
|
||||||
|
if (!chosen) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} />
|
||||||
|
<AdminNotice
|
||||||
|
title={t('noProjectsTitle')}
|
||||||
|
action={
|
||||||
|
manages ? (
|
||||||
|
<Link href={adminNewProjectPath} className={quietLinkClass}>
|
||||||
|
{t('toProjects')}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{manages ? t('noProjectsForAdmin') : t('noProjects')}
|
||||||
|
</AdminNotice>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const [media, types] = await Promise.all([listMediaForProject(chosen.id), listActivePostTypes()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<EntryEditor
|
||||||
|
projects={projects.map(project => ({
|
||||||
|
id: project.id,
|
||||||
|
slug: project.slug,
|
||||||
|
name: project.name,
|
||||||
|
code: projectCode(project),
|
||||||
|
color: project.color,
|
||||||
|
}))}
|
||||||
|
types={toTypeOptions(types, locale)}
|
||||||
|
media={toEntryMediaList(media)}
|
||||||
|
accept={supportedMimes().join(',')}
|
||||||
|
defaultProjectId={chosen.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Metadata, Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { TbPlus } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { EntryFilters } from '~/components/admin/EntryFilters'
|
||||||
|
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
|
||||||
|
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
|
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { listEntries } from '~/data/repositories/entries'
|
||||||
|
import { listAllProjects } from '~/data/repositories/projects'
|
||||||
|
import { listPostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
||||||
|
import { isPostStatus } from '~/domain/post-status'
|
||||||
|
import { isPostTypeKey, postTypeLabel } from '~/domain/post-type'
|
||||||
|
import { adminEntriesPath, adminNewEntryPath, adminEntryPath } from '~/lib/admin-routes'
|
||||||
|
import { isAdmin } from '~/lib/auth-access'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
import { toTypeOptions } from '~/lib/post-types'
|
||||||
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
|
const perPage = 25
|
||||||
|
|
||||||
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
function single(value: string | string[] | undefined): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.entries')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminEntriesPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const viewer = await requireAdminArea()
|
||||||
|
const t = await getTranslations('admin.entries')
|
||||||
|
const params = await searchParams
|
||||||
|
const manages = isAdmin(viewer)
|
||||||
|
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const types = await listPostTypes()
|
||||||
|
|
||||||
|
const projectSlug = single(params.project)
|
||||||
|
const statusValue = single(params.status)
|
||||||
|
const typeValue = single(params.type)
|
||||||
|
const search = single(params.q)
|
||||||
|
const page = Math.max(1, Number(single(params.page)) || 1)
|
||||||
|
const selected = projects.find(project => project.slug === projectSlug)
|
||||||
|
const filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== ''
|
||||||
|
|
||||||
|
const result = projects.length === 0
|
||||||
|
? { items: [], total: 0 }
|
||||||
|
: await listEntries({
|
||||||
|
projectIds: manages ? undefined : projects.map(project => project.id),
|
||||||
|
projectId: selected?.id,
|
||||||
|
status: isPostStatus(statusValue) ? statusValue : undefined,
|
||||||
|
type: isPostTypeKey(typeValue) ? typeValue : undefined,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pages = Math.max(1, Math.ceil(result.total / perPage))
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
|
||||||
|
if (projectSlug !== '') {
|
||||||
|
query.set('project', projectSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusValue !== '') {
|
||||||
|
query.set('status', statusValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeValue !== '') {
|
||||||
|
query.set('type', typeValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search !== '') {
|
||||||
|
query.set('q', search)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagePath(target: number): Route {
|
||||||
|
const next = new URLSearchParams(query)
|
||||||
|
|
||||||
|
if (target > 1) {
|
||||||
|
next.set('page', String(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = next.toString()
|
||||||
|
|
||||||
|
return (suffix === '' ? adminEntriesPath : `${adminEntriesPath}?${suffix}`) as Route
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('intro')}
|
||||||
|
actions={
|
||||||
|
projects.length > 0 ? (
|
||||||
|
<Link href={adminNewEntryPath} className={ghostButtonClass}>
|
||||||
|
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AdminNotice title={t('noProjectsTitle')}>{manages ? t('noProjectsForAdmin') : t('noProjects')}</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EntryFilters
|
||||||
|
projects={projects.map(project => ({ slug: project.slug, name: project.name }))}
|
||||||
|
types={toTypeOptions(types, locale)}
|
||||||
|
project={projectSlug}
|
||||||
|
status={statusValue}
|
||||||
|
type={typeValue}
|
||||||
|
search={search}
|
||||||
|
filtered={filtered}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<span>{t('meta', { count: result.total })}</span>}>{t('listTitle')}</SectionLabel>
|
||||||
|
|
||||||
|
{result.items.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={filtered ? t('emptyFilteredTitle') : t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
filtered ? (
|
||||||
|
<Link href={adminEntriesPath} className={quietLinkClass}>
|
||||||
|
{t('filters.reset')}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href={adminNewEntryPath} className={quietLinkClass}>
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filtered ? t('emptyFiltered') : t('empty')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-3xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.number')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.title')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.project')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.type')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.audience')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.status')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.date')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.author')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
|
||||||
|
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link
|
||||||
|
href={adminEntryPath(item.id)}
|
||||||
|
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(item.projectColor)}
|
||||||
|
className={`size-2.5 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
{item.projectName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td>
|
||||||
|
<td className={cellClass}>{t(`audience.${item.audience}`)}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<EntryStatusChip status={item.status} label={t(`status.${item.status}`)} />
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro`}>
|
||||||
|
<EntryDate date={item.publishAt ?? item.updatedAt} />
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>{item.authorName ?? t('noAuthor')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pages > 1 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-6 border-t border-rule pt-4">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link href={pagePath(page - 1)} className={quietLinkClass}>
|
||||||
|
{t('pagination.previous')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<span className="font-mono text-micro uppercase tracking-mark text-ink-3 tabular-nums">
|
||||||
|
{t('pagination.status', { page, pages })}
|
||||||
|
</span>
|
||||||
|
{page < pages ? (
|
||||||
|
<Link href={pagePath(page + 1)} className={quietLinkClass}>
|
||||||
|
{t('pagination.next')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AppShell } from '~/components/layout/AppShell'
|
||||||
|
import { AdminNav } from '~/components/layout/AdminNav'
|
||||||
|
import { Wrap } from '~/components/layout/Wrap'
|
||||||
|
import { Wordmark } from '~/components/layout/Wordmark'
|
||||||
|
import { loadHighestNumber } from '~/data/repositories/archive'
|
||||||
|
import { requireSession } from '~/lib/auth-guards'
|
||||||
|
import { currentPath } from '~/lib/request-path'
|
||||||
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
|
const scope: ViewerScope = 'internal'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
const viewer = await requireSession(await currentPath())
|
||||||
|
const number = await loadHighestNumber({ scope })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
nav={<AdminNav viewer={viewer} number={number} />}
|
||||||
|
mark={<Wordmark number={number} />}
|
||||||
|
>
|
||||||
|
<main id="content">
|
||||||
|
<Wrap className="pb-16 pt-8">{children}</Wrap>
|
||||||
|
</main>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbArrowNarrowLeft } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { MediaCard } from '~/components/admin/MediaCard'
|
||||||
|
import { MediaUploadForm } from '~/components/admin/MediaUploadForm'
|
||||||
|
import { ghostButtonClass } from '~/components/admin/styles'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { listMediaForProject } from '~/data/repositories/media'
|
||||||
|
import { findProjectBySlug } from '~/data/repositories/projects'
|
||||||
|
import { requireProjectAccess } from '~/lib/auth-guards'
|
||||||
|
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
|
||||||
|
import { supportedMimes } from '~/lib/media-images'
|
||||||
|
|
||||||
|
export default async function AdminProjectMediaPage({ params }: { params: Promise<{ project: string }> }) {
|
||||||
|
const { project: slug } = await params
|
||||||
|
const project = await findProjectBySlug(slug)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireProjectAccess(project.id, adminMediaProjectPath(project.slug))
|
||||||
|
|
||||||
|
const t = await getTranslations('media')
|
||||||
|
const items = await listMediaForProject(project.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={project.name}
|
||||||
|
intro={t('projectIntro')}
|
||||||
|
actions={
|
||||||
|
<Link href={adminMediaPath} className={ghostButtonClass}>
|
||||||
|
<TbArrowNarrowLeft aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MediaUploadForm project={project.slug} accept={supportedMimes().join(',')} />
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<span>{t('projectMeta', { count: items.length })}</span>}>{t('library')}</SectionLabel>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<AdminNotice title={t('emptyLibraryTitle')}>{t('emptyLibrary')}</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-4 p-0">
|
||||||
|
{items.map(item => (
|
||||||
|
<MediaCard key={item.id} item={item} project={project.slug} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbArrowNarrowRight } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { metaClass } from '~/components/admin/styles'
|
||||||
|
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { countMediaForProject } from '~/data/repositories/media'
|
||||||
|
import { listAllProjects } from '~/data/repositories/projects'
|
||||||
|
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
||||||
|
import { isAdmin } from '~/lib/auth-access'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
|
||||||
|
|
||||||
|
export default async function AdminMediaPage() {
|
||||||
|
const viewer = await requireAdminArea(adminMediaPath)
|
||||||
|
const t = await getTranslations('media')
|
||||||
|
const projects = isAdmin(viewer) ? await listAllProjects() : await listProjectsForUser(viewer.id)
|
||||||
|
const counts = await Promise.all(projects.map(project => countMediaForProject(project.id)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AdminNotice title={t('emptyProjectsTitle')}>{t('emptyProjects')}</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<li key={project.id} className="border-b border-rule last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={adminMediaProjectPath(project.slug)}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-2 py-3 text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(project.color)}
|
||||||
|
className={`size-2.5 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
<span className="font-display text-base font-semibold">{project.name}</span>
|
||||||
|
<span className={metaClass}>{t('projectMeta', { count: counts[index] ?? 0 })}</span>
|
||||||
|
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||||
|
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { canOpenAdmin } from '~/lib/auth-access'
|
||||||
|
import { requireSession } from '~/lib/auth-guards'
|
||||||
|
import { adminPath } from '~/lib/auth-routes'
|
||||||
|
|
||||||
|
export default async function AdminNoAccessPage() {
|
||||||
|
const viewer = await requireSession()
|
||||||
|
const t = await getTranslations('admin.noAccess')
|
||||||
|
const hasSomeAccess = canOpenAdmin(viewer)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 pt-12">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-title font-bold text-balance">{t('title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminNotice
|
||||||
|
tone="alert"
|
||||||
|
action={
|
||||||
|
hasSomeAccess ? (
|
||||||
|
<Link href={adminPath} className="font-mono text-small">
|
||||||
|
{t('toOverview')}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasSomeAccess ? t('forModerator') : t('forNobody')}
|
||||||
|
</AdminNotice>
|
||||||
|
|
||||||
|
<p className="m-0 max-w-read text-small text-pretty text-ink-3">{t('wrongAccount')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbArrowNarrowRight, TbPhoto, TbPlus } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { ghostButtonClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { listClients } from '~/data/repositories/clients'
|
||||||
|
import { listAllProjects, listBrands } from '~/data/repositories/projects'
|
||||||
|
import { listUsers } from '~/data/repositories/users'
|
||||||
|
import { listProjectsForUser } from '~/data/repositories/user-roles'
|
||||||
|
import { projectCode } from '~/domain/project-code'
|
||||||
|
import {
|
||||||
|
adminBrandsPath,
|
||||||
|
adminClientsPath,
|
||||||
|
adminNewEntryPath,
|
||||||
|
adminNewProjectPath,
|
||||||
|
adminProjectPath,
|
||||||
|
adminProjectsPath,
|
||||||
|
adminUsersPath,
|
||||||
|
} from '~/lib/admin-routes'
|
||||||
|
import { isAdmin } from '~/lib/auth-access'
|
||||||
|
import { requireAdminArea } from '~/lib/auth-guards'
|
||||||
|
import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes'
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const viewer = await requireAdminArea()
|
||||||
|
const t = await getTranslations('admin')
|
||||||
|
const e = await getTranslations('admin.entries')
|
||||||
|
const m = await getTranslations('media')
|
||||||
|
const manages = isAdmin(viewer)
|
||||||
|
const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id)
|
||||||
|
const brands = manages ? await listBrands() : []
|
||||||
|
const users = manages ? await listUsers() : []
|
||||||
|
const clients = manages ? await listClients() : []
|
||||||
|
|
||||||
|
const tiles = manages
|
||||||
|
? [
|
||||||
|
{ href: adminProjectsPath, label: t('nav.projects'), value: projects.length },
|
||||||
|
{ href: adminBrandsPath, label: t('nav.brands'), value: brands.length },
|
||||||
|
{ href: adminUsersPath, label: t('nav.users'), value: users.length },
|
||||||
|
{
|
||||||
|
href: adminClientsPath,
|
||||||
|
label: t('nav.clients'),
|
||||||
|
value: clients.filter(client => client.revokedAt === null).length,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={manages ? t('role.admin') : t('role.moderator')}
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('intro')}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<Link href={adminNewEntryPath} className={ghostButtonClass}>
|
||||||
|
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{e('create')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link href={adminMediaPath} className={ghostButtonClass}>
|
||||||
|
<TbPhoto aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{m('title')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tiles.length > 0 ? (
|
||||||
|
<ul className="m-0 grid list-none grid-cols-2 gap-px border border-rule bg-rule p-0 md:grid-cols-4">
|
||||||
|
{tiles.map(tile => (
|
||||||
|
<li key={tile.href} className="bg-surface">
|
||||||
|
<Link href={tile.href} className="flex flex-col gap-2 px-5 py-4 no-underline">
|
||||||
|
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{tile.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-title font-semibold text-ink tabular-nums">{tile.value}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<span>{t('projectsMeta', { count: projects.length })}</span>}>
|
||||||
|
{t('yourProjects')}
|
||||||
|
</SectionLabel>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
manages ? (
|
||||||
|
<Link href={adminNewProjectPath} className={quietLinkClass}>
|
||||||
|
{t('emptyAction')}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{manages ? t('emptyForAdmin') : t('empty')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
|
{projects.map(project => (
|
||||||
|
<li
|
||||||
|
key={project.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(project.color)}
|
||||||
|
className={`size-2.5 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={adminMediaProjectPath(project.slug)}
|
||||||
|
className="flex items-center gap-2 font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
<TbArrowNarrowRight aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
<span className="font-mono text-micro font-semibold uppercase tracking-mark text-ink-3">
|
||||||
|
{projectCode(project)}
|
||||||
|
</span>
|
||||||
|
{project.isActive ? null : (
|
||||||
|
<span className="font-mono text-micro uppercase tracking-mark text-signal">
|
||||||
|
{t('inactive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||||
|
{manages ? (
|
||||||
|
<Link href={adminProjectPath(project.id)} className={quietLinkClass}>
|
||||||
|
{t('edit')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { PostTypeDeleteButton } from '~/components/admin/PostTypeDeleteButton'
|
||||||
|
import { PostTypeForm } from '~/components/admin/PostTypeForm'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { countPostsOfType, findPostTypeById } from '~/data/repositories/post-types'
|
||||||
|
import { postTypeLabel } from '~/domain/post-type'
|
||||||
|
import { isUuid } from '~/lib/admin-forms'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
|
type Params = Promise<{ id: string }>
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||||
|
const { id } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const type = isUuid(id) ? await findPostTypeById(id) : undefined
|
||||||
|
|
||||||
|
return { title: `${type ? postTypeLabel(type, locale) : t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditPostTypePage({ params }: { params: Params }) {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const type = isUuid(id) ? await findPostTypeById(id) : undefined
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const postCount = await countPostsOfType(type.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={postTypeLabel(type, locale)} intro={t('editIntro')} />
|
||||||
|
|
||||||
|
<PostTypeForm
|
||||||
|
postType={{
|
||||||
|
id: type.id,
|
||||||
|
key: type.key,
|
||||||
|
labelDe: type.labelDe,
|
||||||
|
labelEn: type.labelEn,
|
||||||
|
color: type.color,
|
||||||
|
sort: type.sort,
|
||||||
|
isActive: type.isActive,
|
||||||
|
}}
|
||||||
|
locked={postCount > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 border-t border-rule pt-8">
|
||||||
|
<SectionLabel meta={<span>{t('usage', { count: postCount })}</span>}>{t('deleteTitle')}</SectionLabel>
|
||||||
|
<PostTypeDeleteButton id={type.id} postCount={postCount} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { PostTypeForm } from '~/components/admin/PostTypeForm'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
|
||||||
|
return { title: `${t('createTitle')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewPostTypePage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
|
||||||
|
<PostTypeForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { TbPlus } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { countPostsPerPostType, listPostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { postTypeLabel } from '~/domain/post-type'
|
||||||
|
import { adminNewPostTypePath, adminPostTypePath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
import type { Locale } from '~/i18n/config'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPostTypesPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.postTypes')
|
||||||
|
const locale = await getLocale() as Locale
|
||||||
|
const [types, counts] = await Promise.all([listPostTypes(), countPostsPerPostType()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('intro')}
|
||||||
|
actions={
|
||||||
|
<Link href={adminNewPostTypePath} className={ghostButtonClass}>
|
||||||
|
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{types.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewPostTypePath} className={quietLinkClass}>
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('empty')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.label')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.key')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.posts')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.sort')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.active')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>
|
||||||
|
<span className="sr-only">{t('columns.edit')}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{types.map(type => (
|
||||||
|
<tr key={type.id}>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link
|
||||||
|
href={adminPostTypePath(type.id)}
|
||||||
|
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{postTypeLabel(type, locale)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro`}>{type.key}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(type.color)}
|
||||||
|
className={`size-3 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-micro uppercase tracking-mark">{type.color}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(type.id) ?? 0}</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>{type.sort}</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
|
||||||
|
{type.isActive ? t('active') : t('inactive')}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link href={adminPostTypePath(type.id)} className={quietLinkClass}>
|
||||||
|
{t('columns.edit')}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { ProjectForm } from '~/components/admin/ProjectForm'
|
||||||
|
import { quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { findProjectById, listBrands } from '~/data/repositories/projects'
|
||||||
|
import { isUuid } from '~/lib/admin-forms'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
import { projectPath } from '~/lib/routes'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { id } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
const project = isUuid(id) ? await findProjectById(id) : undefined
|
||||||
|
|
||||||
|
return { title: `${project?.name ?? t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const project = isUuid(id) ? await findProjectById(id) : undefined
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
const brands = await listBrands()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={project.name}
|
||||||
|
intro={t('editIntro')}
|
||||||
|
actions={
|
||||||
|
<Link href={projectPath(project.slug)} className={quietLinkClass}>
|
||||||
|
{t('toArchive')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectForm
|
||||||
|
brands={brands.map(brand => ({ id: brand.id, name: brand.name }))}
|
||||||
|
project={{
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
slug: project.slug,
|
||||||
|
code: project.code ?? '',
|
||||||
|
description: project.description ?? '',
|
||||||
|
color: project.color,
|
||||||
|
brandId: project.brandId,
|
||||||
|
sort: project.sort,
|
||||||
|
isActive: project.isActive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { ProjectForm } from '~/components/admin/ProjectForm'
|
||||||
|
import { quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { listBrands } from '~/data/repositories/projects'
|
||||||
|
import { adminNewBrandPath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
|
||||||
|
return { title: `${t('createTitle')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewProjectPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
const brands = await listBrands()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('createTitle')} intro={t('createIntro')} />
|
||||||
|
|
||||||
|
{brands.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('noBrandsTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewBrandPath} className={quietLinkClass}>
|
||||||
|
{t('toBrands')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('noBrands')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<ProjectForm brands={brands.map(brand => ({ id: brand.id, name: brand.name }))} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { TbPlus } from 'react-icons/tb'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { countPostsPerProject, listAllProjects, listBrands } from '~/data/repositories/projects'
|
||||||
|
import { projectCode } from '~/domain/project-code'
|
||||||
|
import { adminNewBrandPath, adminNewProjectPath, adminProjectPath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminProjectsPage() {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.projects')
|
||||||
|
const [projects, brands, counts] = await Promise.all([
|
||||||
|
listAllProjects(),
|
||||||
|
listBrands(),
|
||||||
|
countPostsPerProject(),
|
||||||
|
])
|
||||||
|
const brandNames = new Map(brands.map(brand => [brand.id, brand.name]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('intro')}
|
||||||
|
actions={
|
||||||
|
brands.length > 0 ? (
|
||||||
|
<Link href={adminNewProjectPath} className={ghostButtonClass}>
|
||||||
|
<TbPlus aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{brands.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('noBrandsTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewBrandPath} className={quietLinkClass}>
|
||||||
|
{t('toBrands')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('noBrands')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('emptyTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewProjectPath} className={quietLinkClass}>
|
||||||
|
{t('create')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('empty')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-3xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.code')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.brand')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.color')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.posts')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.state')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>
|
||||||
|
<span className="sr-only">{t('columns.edit')}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map(project => (
|
||||||
|
<tr key={project.id}>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link
|
||||||
|
href={adminProjectPath(project.id)}
|
||||||
|
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}>
|
||||||
|
{projectCode(project)}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>{brandNames.get(project.brandId) ?? t('columns.brandMissing')}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectStyle(project.color)}
|
||||||
|
className={`size-3 shrink-0 ${projectSurface}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-micro uppercase tracking-mark">{project.color}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>{counts.get(project.id) ?? 0}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<span className={project.isActive ? 'text-ink-2' : 'text-signal'}>
|
||||||
|
{project.isActive ? t('active') : t('inactive')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link href={adminProjectPath(project.id)} className={quietLinkClass}>
|
||||||
|
{t('columns.edit')}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { UserAdminToggle } from '~/components/admin/UserAdminToggle'
|
||||||
|
import { UserRoleRow } from '~/components/admin/UserRoleRow'
|
||||||
|
import { hintClass, labelClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
|
import { listAllProjects } from '~/data/repositories/projects'
|
||||||
|
import { findUserById } from '~/data/repositories/users'
|
||||||
|
import { listAssignmentsForUser } from '~/data/repositories/user-roles'
|
||||||
|
import { projectCode } from '~/domain/project-code'
|
||||||
|
import { adminNewProjectPath, adminUsersPath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { id } = await params
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.users')
|
||||||
|
const user = await findUserById(id)
|
||||||
|
|
||||||
|
return { title: `${user?.name ?? t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminUserPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const viewer = await requireAdmin()
|
||||||
|
const { id } = await params
|
||||||
|
const user = await findUserById(id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.users')
|
||||||
|
const [projects, assignments] = await Promise.all([listAllProjects(), listAssignmentsForUser(user.id)])
|
||||||
|
const assigned = new Set(assignments.map(assignment => assignment.projectId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 pt-12">
|
||||||
|
<AdminHeading
|
||||||
|
eyebrow={t('eyebrow')}
|
||||||
|
title={user.name.trim() === '' ? user.email : user.name}
|
||||||
|
intro={user.email}
|
||||||
|
actions={
|
||||||
|
<Link href={adminUsersPath} className={quietLinkClass}>
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel>{t('adminSection')}</SectionLabel>
|
||||||
|
<p className="m-0 max-w-read text-base text-pretty text-ink-2">
|
||||||
|
{user.isAdmin ? t('isAdmin') : t('isNotAdmin')}
|
||||||
|
</p>
|
||||||
|
<UserAdminToggle userId={user.id} isAdmin={user.isAdmin} isSelf={user.id === viewer.id} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<SectionLabel meta={<span>{t('assignedMeta', { count: assigned.size })}</span>}>
|
||||||
|
{t('rolesSection')}
|
||||||
|
</SectionLabel>
|
||||||
|
|
||||||
|
{user.isAdmin ? (
|
||||||
|
<AdminNotice>{t('adminHasAll')}</AdminNotice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AdminNotice
|
||||||
|
title={t('noProjectsTitle')}
|
||||||
|
action={
|
||||||
|
<Link href={adminNewProjectPath} className={quietLinkClass}>
|
||||||
|
{t('toProjects')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('noProjects')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
|
{projects.map(project => (
|
||||||
|
<UserRoleRow
|
||||||
|
key={project.id}
|
||||||
|
userId={user.id}
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
projectCode={projectCode(project)}
|
||||||
|
color={project.color}
|
||||||
|
assigned={assigned.has(project.id)}
|
||||||
|
implicit={user.isAdmin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={hintClass}>{t('rolesHint')}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2 border-t border-rule pt-6">
|
||||||
|
<span className={labelClass}>{t('accountSection')}</span>
|
||||||
|
<span className={hintClass}>{t('accountHint')}</span>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { cellClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { listUsers } from '~/data/repositories/users'
|
||||||
|
import { listAllAssignments } from '~/data/repositories/user-roles'
|
||||||
|
import { adminUserPath } from '~/lib/admin-routes'
|
||||||
|
import { requireAdmin } from '~/lib/auth-guards'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('admin.users')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminUsersPage() {
|
||||||
|
const viewer = await requireAdmin()
|
||||||
|
const t = await getTranslations('admin.users')
|
||||||
|
const [users, assignments] = await Promise.all([listUsers(), listAllAssignments()])
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
counts.set(assignment.userId, (counts.get(assignment.userId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 pt-12">
|
||||||
|
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<AdminNotice title={t('emptyTitle')}>{t('empty')}</AdminNotice>
|
||||||
|
) : (
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<table className="w-full min-w-2xl border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.name')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.email')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.role')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.projects')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>
|
||||||
|
<span className="sr-only">{t('columns.edit')}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(user => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link
|
||||||
|
href={adminUserPath(user.id)}
|
||||||
|
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||||
|
>
|
||||||
|
{user.name.trim() === '' ? user.email : user.name}
|
||||||
|
</Link>
|
||||||
|
{user.id === viewer.id ? (
|
||||||
|
<span className="pl-3 font-mono text-micro uppercase tracking-mark text-ink-3">
|
||||||
|
{t('you')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-micro`}>{user.email}</td>
|
||||||
|
<td className={cellClass}>{user.isAdmin ? t('roles.admin') : t('roles.moderator')}</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums`}>
|
||||||
|
{user.isAdmin ? t('all') : counts.get(user.id) ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
<Link href={adminUserPath(user.id)} className={quietLinkClass}>
|
||||||
|
{t('columns.edit')}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Scroller>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminNotice title={t('newAccountTitle')}>{t('newAccount')}</AdminNotice>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { toNextJsHandler } from 'better-auth/next-js'
|
||||||
|
import { auth } from '~/lib/auth'
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
import { getStorage, isSafeObjectPath } from '~/lib/storage'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const cacheControl = 'public, max-age=31536000, immutable'
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await context.params
|
||||||
|
const key = (path ?? []).join('/')
|
||||||
|
|
||||||
|
if (!isSafeObjectPath(key)) {
|
||||||
|
return problem(400, 'invalid_path', 'Der Pfad zeigt aus dem Speicher heraus.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await getStorage().read(key)
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
return problem(404, 'not_found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(object.body), {
|
||||||
|
headers: {
|
||||||
|
'content-type': object.contentType,
|
||||||
|
'content-length': String(object.byteSize),
|
||||||
|
'cache-control': cacheControl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { findProjectBySlug } from '~/data/repositories/projects'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { maxUploadBytes, readUploadBody, uploadMedia, type UploadFailure } from '~/lib/media-upload'
|
||||||
|
import { mediaUrl } from '~/lib/media-url'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
import type { Media } from '~/data/schema'
|
||||||
|
|
||||||
|
const failures: Record<UploadFailure, { status: number, type: string, detail: string }> = {
|
||||||
|
file_missing: { status: 400, type: 'file_missing', detail: 'Es wurde keine Datei im Feld file mitgeschickt.' },
|
||||||
|
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist groesser als ${maxUploadBytes} Bytes.` },
|
||||||
|
unsupported_type: { status: 415, type: 'unsupported_type', detail: 'Dieses Dateiformat wird nicht angenommen.' },
|
||||||
|
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei laesst sich nicht als Bild lesen.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function body(item: Media) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
projectId: item.projectId,
|
||||||
|
kind: item.kind,
|
||||||
|
originalFilename: item.originalFilename,
|
||||||
|
mime: item.mime,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
byteSize: item.byteSize,
|
||||||
|
alt: item.alt,
|
||||||
|
caption: item.caption,
|
||||||
|
url: mediaUrl(item.path),
|
||||||
|
variants: item.variants.map(variant => ({
|
||||||
|
width: variant.width,
|
||||||
|
format: variant.format,
|
||||||
|
url: mediaUrl(variant.path),
|
||||||
|
})),
|
||||||
|
variantError: item.variantError,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.mode !== 'write') {
|
||||||
|
return problem(403, 'forbidden', 'Dieses Token darf nur lesen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let form: FormData
|
||||||
|
|
||||||
|
try {
|
||||||
|
form = await request.formData()
|
||||||
|
} catch {
|
||||||
|
return problem(400, 'invalid_body', 'Der Aufruf muss multipart/form-data sein.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get('file')
|
||||||
|
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return problem(400, 'file_missing', 'Es wurde keine Datei im Feld file mitgeschickt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxUploadBytes) {
|
||||||
|
return problem(413, 'file_too_large', `Die Datei ist groesser als ${maxUploadBytes} Bytes.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = String(form.get('project') ?? '').trim()
|
||||||
|
let projectId = client.projectId ?? undefined
|
||||||
|
|
||||||
|
if (projectId === undefined) {
|
||||||
|
if (requested === '') {
|
||||||
|
return problem(400, 'project_missing', 'Ohne projektgebundenes Token muss project mitgeschickt werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await findProjectBySlug(requested)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return problem(404, 'project_unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
projectId = project.id
|
||||||
|
} else if (requested !== '') {
|
||||||
|
const project = await findProjectBySlug(requested)
|
||||||
|
|
||||||
|
if (!project || project.id !== projectId) {
|
||||||
|
return problem(403, 'forbidden', 'Dieses Token gehoert zu einem anderen Projekt.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadMedia({
|
||||||
|
projectId,
|
||||||
|
filename: file.name,
|
||||||
|
body: await readUploadBody(file),
|
||||||
|
alt: String(form.get('alt') ?? '').trim() || null,
|
||||||
|
caption: String(form.get('caption') ?? '').trim() || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const failure = failures[result.reason]
|
||||||
|
|
||||||
|
return problem(failure.status, failure.type, failure.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(body(result.media), { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { openApiDocument } from '~/lib/openapi'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(document, null, '\t'), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-disposition': 'attachment; filename="logbuch-openapi.json"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { listActivePostTypes } from '~/data/repositories/post-types'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await listActivePostTypes()
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
items: items.map(item => ({
|
||||||
|
key: item.key,
|
||||||
|
labelDe: item.labelDe,
|
||||||
|
labelEn: item.labelEn,
|
||||||
|
color: item.color,
|
||||||
|
sort: item.sort,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { apiSetBlocks } from '~/lib/api-entries'
|
||||||
|
import { entryProblem } from '~/lib/api-problem'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
|
||||||
|
|
||||||
|
const bodySchema = z.object({ blocks: z.unknown() })
|
||||||
|
|
||||||
|
export async function PUT(request: Request, context: { params: Promise<{ slug: string }> }) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params
|
||||||
|
|
||||||
|
if (!idPattern.test(slug)) {
|
||||||
|
return problem(400, 'id_required', 'Zum Setzen der Bloecke wird die Kennung des Beitrags gebraucht.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_body', 'blocks')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiSetBlocks(client, slug, parsed.data.blocks)
|
||||||
|
|
||||||
|
return result.ok ? Response.json(result.value) : entryProblem(result.error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { findPost } from '~/data/repositories/posts'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
|
||||||
|
import { entryProblem } from '~/lib/api-problem'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
teaser: z.string().nullish(),
|
||||||
|
type: z.string().min(1).optional(),
|
||||||
|
audience: z.enum(['internal', 'customer', 'public']).optional(),
|
||||||
|
slug: z.string().nullish(),
|
||||||
|
cover_media_id: z.string().nullish(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params
|
||||||
|
|
||||||
|
if (idPattern.test(slug)) {
|
||||||
|
const result = await apiReadEntry(client, slug)
|
||||||
|
|
||||||
|
return result.ok ? Response.json(result.value) : entryProblem(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return problem(404, 'post_not_found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: { params: Promise<{ slug: string }> }) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params
|
||||||
|
|
||||||
|
if (!idPattern.test(slug)) {
|
||||||
|
return problem(400, 'id_required', 'Zum Aendern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json().catch(() => null))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsed.data
|
||||||
|
const result = await apiUpdateEntry(client, slug, {
|
||||||
|
title: body.title,
|
||||||
|
teaser: body.teaser === undefined ? undefined : body.teaser,
|
||||||
|
type: body.type,
|
||||||
|
audience: body.audience,
|
||||||
|
slug: body.slug === undefined ? undefined : body.slug,
|
||||||
|
coverMediaId: body.cover_media_id === undefined ? undefined : body.cover_media_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.ok ? Response.json(result.value) : entryProblem(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request, context: { params: Promise<{ slug: string }> }) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params
|
||||||
|
|
||||||
|
if (!idPattern.test(slug)) {
|
||||||
|
return problem(400, 'id_required', 'Zum Loeschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiDeleteEntry(client, slug)
|
||||||
|
|
||||||
|
return result.ok ? Response.json(result.value) : entryProblem(result.error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { listPosts } from '~/data/repositories/posts'
|
||||||
|
import { isPostTypeKey } from '~/domain/post-type'
|
||||||
|
import { apiCreateEntry } from '~/lib/api-entries'
|
||||||
|
import { entryProblem } from '~/lib/api-problem'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
project: z.string().min(1).optional(),
|
||||||
|
since: z.coerce.date().optional(),
|
||||||
|
type: z.string().trim().toLowerCase().refine(isPostTypeKey).optional(),
|
||||||
|
tag: z.string().min(1).optional(),
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
per_page: z.coerce.number().int().min(1).max(100).default(25),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_query', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = parsed.data
|
||||||
|
const result = await listPosts({
|
||||||
|
scope: client.scope,
|
||||||
|
projectSlug: query.project,
|
||||||
|
projectId: client.projectId ?? undefined,
|
||||||
|
since: query.since,
|
||||||
|
type: query.type,
|
||||||
|
tag: query.tag,
|
||||||
|
page: query.page,
|
||||||
|
perPage: query.per_page,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
items: result.items,
|
||||||
|
meta: { total: result.total, page: query.page, per_page: query.per_page },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
project: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
teaser: z.string().nullish(),
|
||||||
|
type: z.string().min(1),
|
||||||
|
audience: z.enum(['internal', 'customer', 'public']).optional(),
|
||||||
|
author_name: z.string().nullish(),
|
||||||
|
slug: z.string().nullish(),
|
||||||
|
blocks: z.unknown().optional(),
|
||||||
|
idempotency_key: z.string().min(1).max(200).nullish(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json().catch(() => null))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsed.data
|
||||||
|
const result = await apiCreateEntry(client, {
|
||||||
|
project: body.project,
|
||||||
|
title: body.title,
|
||||||
|
teaser: body.teaser ?? null,
|
||||||
|
type: body.type,
|
||||||
|
audience: body.audience,
|
||||||
|
authorName: body.author_name ?? null,
|
||||||
|
slug: body.slug ?? null,
|
||||||
|
blocks: body.blocks,
|
||||||
|
idempotencyKey: body.idempotency_key ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return entryProblem(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(result.value, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { listProjects } from '~/data/repositories/projects'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await listProjects()
|
||||||
|
const items = client.projectId ? all.filter(project => project.id === client.projectId) : all
|
||||||
|
|
||||||
|
return Response.json({ items })
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { publishDueEntries } from '~/lib/publish-due'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
function allowed(request: Request): boolean {
|
||||||
|
const secret = process.env.CRON_SECRET?.trim()
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = request.headers.get('authorization')
|
||||||
|
|
||||||
|
return header === `Bearer ${secret}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!allowed(request)) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { published } = await publishDueEntries()
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
published: published.map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
number: entry.number,
|
||||||
|
slug: entry.slug,
|
||||||
|
title: entry.title,
|
||||||
|
})),
|
||||||
|
meta: { total: published.length },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { markRead } from '~/data/repositories/reads'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
external_user_id: z.string().min(1),
|
||||||
|
post_ids: z.array(z.uuid()).min(1).max(200),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.projectId) {
|
||||||
|
return problem(422, 'client_without_project')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const marked = await markRead({
|
||||||
|
projectId: client.projectId,
|
||||||
|
externalUserId: parsed.data.external_user_id,
|
||||||
|
scope: client.scope,
|
||||||
|
postIds: parsed.data.post_ids,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ marked })
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
headers: { 'content-type': 'text/markdown; charset=utf-8' },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { listUnread } from '~/data/repositories/reads'
|
||||||
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
|
const querySchema = z.object({ external_user_id: z.string().min(1) })
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const client = await resolveClient(request)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return problem(401, 'unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.projectId) {
|
||||||
|
return problem(422, 'client_without_project')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return problem(400, 'invalid_query', 'external_user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await listUnread({
|
||||||
|
projectId: client.projectId,
|
||||||
|
externalUserId: parsed.data.external_user_id,
|
||||||
|
scope: client.scope,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ count: items.length, items })
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-paper: #f2f1ec;
|
||||||
|
--color-surface: #fbfaf6;
|
||||||
|
--color-ink: #14161a;
|
||||||
|
--color-ink-2: #4c5157;
|
||||||
|
--color-ink-3: #8a8f95;
|
||||||
|
--color-rule: #dedcd3;
|
||||||
|
--color-signal: #c43a22;
|
||||||
|
--color-link: #2b4a9b;
|
||||||
|
--shadow-lift: 0 1.5rem 3rem -1.5rem rgb(20 22 26 / 0.18);
|
||||||
|
--color-positive: #2e7d5b;
|
||||||
|
--color-caution: #b4761a;
|
||||||
|
--color-shot-1: var(--color-rule);
|
||||||
|
--color-shot-2: var(--color-ink-3);
|
||||||
|
--container-page: 63rem;
|
||||||
|
--container-read: 38rem;
|
||||||
|
--font-display: var(--font-archivo), system-ui, sans-serif;
|
||||||
|
--font-body: var(--font-source-serif), Georgia, serif;
|
||||||
|
--font-mono: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||||
|
--text-micro: 0.6875rem;
|
||||||
|
--text-small: 0.8125rem;
|
||||||
|
--text-base: 1.0625rem;
|
||||||
|
--text-lead: 1.25rem;
|
||||||
|
--text-title: 1.75rem;
|
||||||
|
--text-hero: clamp(2.25rem, 4vw, 3.25rem);
|
||||||
|
--text-plate: clamp(2rem, 5vw, 3.5rem);
|
||||||
|
--text-mark: clamp(5rem, 13vw, 10rem);
|
||||||
|
--tracking-label: 0.28em;
|
||||||
|
--tracking-mark: 0.18em;
|
||||||
|
--tracking-plate: 0.42em;
|
||||||
|
--tracking-badge: 0.24em;
|
||||||
|
--tracking-chip: 0.14em;
|
||||||
|
--aspect-cover: 21 / 9;
|
||||||
|
--aspect-shot: 16 / 10;
|
||||||
|
--breakpoint-md: 46rem;
|
||||||
|
--animate-rise: rise 0.5s both cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(0.375rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--shadow-lift: 0 1.5rem 3rem -1.5rem rgb(0 0 0 / 0.65);
|
||||||
|
--color-paper: #121417;
|
||||||
|
--color-surface: #191c20;
|
||||||
|
--color-ink: #edebe4;
|
||||||
|
--color-ink-2: #a9aeb4;
|
||||||
|
--color-ink-3: #757a80;
|
||||||
|
--color-rule: #2a2e34;
|
||||||
|
--color-signal: #e4603f;
|
||||||
|
--color-link: #8aa6ee;
|
||||||
|
--color-positive: #6dbd97;
|
||||||
|
--color-caution: #d9a34e;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--color-paper: #f2f1ec;
|
||||||
|
--color-surface: #fbfaf6;
|
||||||
|
--color-ink: #14161a;
|
||||||
|
--color-ink-2: #4c5157;
|
||||||
|
--color-ink-3: #8a8f95;
|
||||||
|
--color-rule: #dedcd3;
|
||||||
|
--color-signal: #c43a22;
|
||||||
|
--color-link: #2b4a9b;
|
||||||
|
--color-positive: #2e7d5b;
|
||||||
|
--color-caution: #b4761a;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
background: var(--color-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration-thickness: 0.0625rem;
|
||||||
|
text-underline-offset: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 0.125rem solid var(--color-signal);
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-signal);
|
||||||
|
color: var(--color-paper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-fill {
|
||||||
|
background: var(--plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .plate-fill {
|
||||||
|
background: color-mix(in srgb, var(--plate) 88%, var(--color-paper));
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-theme-logbuch {
|
||||||
|
--os-size: 0.625rem;
|
||||||
|
--os-padding-perpendicular: 0.125rem;
|
||||||
|
--os-padding-axis: 0.125rem;
|
||||||
|
--os-track-border-radius: 1rem;
|
||||||
|
--os-track-bg: transparent;
|
||||||
|
--os-track-bg-hover: transparent;
|
||||||
|
--os-track-bg-active: transparent;
|
||||||
|
--os-handle-border-radius: 1rem;
|
||||||
|
--os-handle-min-size: 2rem;
|
||||||
|
--os-handle-interactive-area-offset: 0.25rem;
|
||||||
|
--os-handle-bg: color-mix(in srgb, var(--color-ink-3) 50%, transparent);
|
||||||
|
--os-handle-bg-hover: color-mix(in srgb, var(--color-ink-3) 75%, transparent);
|
||||||
|
--os-handle-bg-active: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-lift {
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import './globals.css'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { getLocale, getTranslations } from 'next-intl/server'
|
||||||
|
import { fontVariables } from '~/lib/fonts'
|
||||||
|
import { themeInitScript } from '~/components/ui/theme'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('app')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('name'),
|
||||||
|
description: t('tagline'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const locale = await getLocale()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} className={fontVariables} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||||
|
</head>
|
||||||
|
<body className="h-dvh overflow-hidden bg-paper font-body text-base text-ink antialiased">
|
||||||
|
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Metadata, Route } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { LoginForm } from '~/components/admin/LoginForm'
|
||||||
|
import { MagicLinkForm } from '~/components/admin/MagicLinkForm'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { magicLinkMinutes } from '~/domain/magic-link'
|
||||||
|
import { readViewer } from '~/lib/auth-guards'
|
||||||
|
import { internalTarget, overviewTarget } from '~/lib/auth-routes'
|
||||||
|
import { readText, type SearchParams } from '~/lib/query'
|
||||||
|
import { overviewPath } from '~/lib/routes'
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const app = await getTranslations('app')
|
||||||
|
const t = await getTranslations('login')
|
||||||
|
|
||||||
|
return { title: `${t('title')} - ${app('name')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const params = await searchParams
|
||||||
|
const target = internalTarget(readText(params.next), overviewTarget)
|
||||||
|
const viewer = await readViewer()
|
||||||
|
|
||||||
|
if (viewer) {
|
||||||
|
redirect(target as Route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('login')
|
||||||
|
const next = target === overviewTarget ? undefined : target
|
||||||
|
const failed = readText(params.error) !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="content" className="h-dvh w-full">
|
||||||
|
<Scroller className="h-full" options={{ overflow: { x: 'hidden' } }}>
|
||||||
|
<div className="flex min-h-dvh w-full items-center justify-center px-6 py-12">
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{t('eyebrow')}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-title font-bold text-balance">{t('title')}</h1>
|
||||||
|
<p className="m-0 text-base text-pretty text-ink-2">{t('intro')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<AdminNotice tone="alert">
|
||||||
|
{t('magic.linkFailed', { minutes: magicLinkMinutes })}
|
||||||
|
</AdminNotice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MagicLinkForm next={next} />
|
||||||
|
|
||||||
|
<details className="border-t border-rule pt-6">
|
||||||
|
<summary className="cursor-pointer font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-signal">
|
||||||
|
{t('magic.passwordTitle')}
|
||||||
|
</summary>
|
||||||
|
<div className="pt-6">
|
||||||
|
<LoginForm next={next} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-rule pt-6">
|
||||||
|
<p className="m-0 text-small text-pretty text-ink-3">{t('noAccount')}</p>
|
||||||
|
<Link href={overviewPath()} className="font-mono text-small">
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Scroller>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { EmptyNotice } from '~/components/archive/EmptyNotice'
|
||||||
|
import { ViewHeader } from '~/components/archive/ViewHeader'
|
||||||
|
import { Wrap } from '~/components/layout/Wrap'
|
||||||
|
import { Watermark } from '~/components/ui/Watermark'
|
||||||
|
import { overviewPath, searchPath } from '~/lib/routes'
|
||||||
|
|
||||||
|
const code = 404
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
const t = useTranslations('notFound')
|
||||||
|
const nav = useTranslations('nav')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="content">
|
||||||
|
<Wrap>
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<Watermark number={code} className="-right-2 -top-4" />
|
||||||
|
<div className="relative">
|
||||||
|
<ViewHeader
|
||||||
|
title={t('title')}
|
||||||
|
intro={t('hint')}
|
||||||
|
eyebrow={
|
||||||
|
<span className="font-semibold tracking-plate text-signal tabular-nums">
|
||||||
|
{t('code')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pb-14">
|
||||||
|
<EmptyNotice
|
||||||
|
title={t('nextTitle')}
|
||||||
|
action={
|
||||||
|
<span className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<Link href={overviewPath()} className="font-mono text-small">
|
||||||
|
{t('home')}
|
||||||
|
</Link>
|
||||||
|
<Link href={searchPath()} className="font-mono text-small">
|
||||||
|
{nav('search')}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('next')}
|
||||||
|
</EmptyNotice>
|
||||||
|
</div>
|
||||||
|
</Wrap>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { inputClass } from './styles'
|
||||||
|
import { isColor } from '~/domain/color'
|
||||||
|
|
||||||
|
export type AdminColorFieldProps = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
label: ReactNode
|
||||||
|
pickerLabel: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminColorField({ id, name, label, pickerLabel, value, onChange, hint, error }: AdminColorFieldProps) {
|
||||||
|
return (
|
||||||
|
<AdminField id={id} label={label} hint={hint} error={error}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id={`${id}-picker`}
|
||||||
|
type="color"
|
||||||
|
aria-label={pickerLabel}
|
||||||
|
value={isColor(value) ? value : '#000000'}
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
className="h-10 w-12 shrink-0 cursor-pointer border border-rule bg-surface p-1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminField>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { errorClass, hintClass, labelClass } from './styles'
|
||||||
|
|
||||||
|
export type AdminFieldProps = {
|
||||||
|
id: string
|
||||||
|
label: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminField({ id, label, children, hint, error, className }: AdminFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-w-0 flex-col gap-2 ${className ?? ''}`}>
|
||||||
|
<label htmlFor={id} className={labelClass}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint ? <span className={hintClass}>{hint}</span> : null}
|
||||||
|
{error ? (
|
||||||
|
<span role="alert" className={errorClass}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { AdminNotice } from './AdminNotice'
|
||||||
|
import type { AdminFormState } from '~/lib/admin-forms'
|
||||||
|
|
||||||
|
export type AdminFormMessageProps = {
|
||||||
|
state: AdminFormState
|
||||||
|
silentOnDone?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminFormMessage({ state, silentOnDone = false }: AdminFormMessageProps) {
|
||||||
|
const t = useTranslations('admin.messages')
|
||||||
|
|
||||||
|
if (state.status === 'idle' || !state.message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'ok' && silentOnDone) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminNotice tone={state.status === 'error' ? 'alert' : 'done'}>{t(state.message)}</AdminNotice>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type AdminHeadingProps = {
|
||||||
|
eyebrow?: ReactNode
|
||||||
|
title: ReactNode
|
||||||
|
intro?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminHeading({ eyebrow, title, intro, actions }: AdminHeadingProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
{eyebrow ? (
|
||||||
|
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<h1 className="text-title font-bold text-balance">{title}</h1>
|
||||||
|
{intro ? <p className="m-0 max-w-read text-base text-pretty text-ink-2">{intro}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap items-center gap-3">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { TbAlertTriangle, TbCircleCheck, TbInfoCircle } from 'react-icons/tb'
|
||||||
|
|
||||||
|
export type AdminNoticeTone = 'info' | 'alert' | 'done'
|
||||||
|
|
||||||
|
export type AdminNoticeProps = {
|
||||||
|
children: ReactNode
|
||||||
|
title?: ReactNode
|
||||||
|
action?: ReactNode
|
||||||
|
tone?: AdminNoticeTone
|
||||||
|
}
|
||||||
|
|
||||||
|
const tones: Record<AdminNoticeTone, string> = {
|
||||||
|
info: 'border-rule',
|
||||||
|
alert: 'border-signal',
|
||||||
|
done: 'border-positive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons: Record<AdminNoticeTone, string> = {
|
||||||
|
info: 'text-ink-3',
|
||||||
|
alert: 'text-signal',
|
||||||
|
done: 'text-positive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const glyphs = {
|
||||||
|
info: TbInfoCircle,
|
||||||
|
alert: TbAlertTriangle,
|
||||||
|
done: TbCircleCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminNotice({ children, title, action, tone = 'info' }: AdminNoticeProps) {
|
||||||
|
const Icon = glyphs[tone]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={tone === 'alert' ? 'alert' : undefined}
|
||||||
|
className={`flex gap-3 border-l-2 bg-surface px-5 py-4 ${tones[tone]}`}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className={`mt-1 size-4 shrink-0 ${icons[tone]}`} />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{title ? (
|
||||||
|
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<p className="m-0 max-w-read text-base text-pretty text-ink-2">{children}</p>
|
||||||
|
{action ? <span className="font-mono text-small text-ink-2">{action}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useFormStatus } from 'react-dom'
|
||||||
|
import { ghostButtonClass, primaryButtonClass } from './styles'
|
||||||
|
|
||||||
|
export type AdminSubmitProps = {
|
||||||
|
children: ReactNode
|
||||||
|
pendingLabel: ReactNode
|
||||||
|
icon?: ReactNode
|
||||||
|
quiet?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
name?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSubmit({ children, pendingLabel, icon, quiet = false, disabled = false, name, value }: AdminSubmitProps) {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
disabled={pending || disabled}
|
||||||
|
className={quiet ? ghostButtonClass : primaryButtonClass}
|
||||||
|
>
|
||||||
|
{icon ? <span aria-hidden="true" className="flex shrink-0">{icon}</span> : null}
|
||||||
|
{pending ? pendingLabel : children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
|
||||||
|
export type ApiEndpointProps = {
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
summary: string
|
||||||
|
auth: string
|
||||||
|
example: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiEndpoint({ method, path, summary, auth, example, children }: ApiEndpointProps) {
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-3 border-t border-rule pt-5">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-3">
|
||||||
|
<span className="border border-ink px-2 py-0.5 font-mono text-micro font-semibold uppercase tracking-label text-ink">
|
||||||
|
{method}
|
||||||
|
</span>
|
||||||
|
<code className="font-mono text-small text-ink">{path}</code>
|
||||||
|
<span className="ml-auto font-mono text-micro uppercase tracking-mark text-ink-3">{auth}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="m-0 max-w-read text-pretty text-ink-2">{summary}</p>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||||
|
<pre className="m-0 w-max min-w-full bg-surface p-4 font-mono text-small text-ink-2">{example}</pre>
|
||||||
|
</Scroller>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbDeviceFloppy } from 'react-icons/tb'
|
||||||
|
import { AdminColorField } from './AdminColorField'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { AdminFormMessage } from './AdminFormMessage'
|
||||||
|
import { AdminSubmit } from './AdminSubmit'
|
||||||
|
import { inputClass, quietLinkClass } from './styles'
|
||||||
|
import { saveBrand } from '~/lib/admin-actions'
|
||||||
|
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||||
|
import { adminBrandsPath } from '~/lib/admin-routes'
|
||||||
|
import { defaultColor } from '~/domain/color'
|
||||||
|
import { slugify } from '~/domain/slug'
|
||||||
|
|
||||||
|
export type BrandFormValues = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
color: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrandFormProps = {
|
||||||
|
brand?: BrandFormValues
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandForm({ brand }: BrandFormProps) {
|
||||||
|
const t = useTranslations('admin.brands')
|
||||||
|
const errors = useTranslations('admin.errors')
|
||||||
|
const [state, formAction] = useActionState(saveBrand, emptyAdminFormState)
|
||||||
|
const [name, setName] = useState(brand?.name ?? '')
|
||||||
|
const [slug, setSlug] = useState(brand?.slug ?? '')
|
||||||
|
const [slugTouched, setSlugTouched] = useState((brand?.slug ?? '') !== '')
|
||||||
|
const [color, setColor] = useState(brand?.color ?? defaultColor)
|
||||||
|
const [sort, setSort] = useState(String(brand?.sort ?? 0))
|
||||||
|
|
||||||
|
function fieldError(field: string): string | undefined {
|
||||||
|
const key = state.fields?.[field]
|
||||||
|
return key ? errors(key) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-8">
|
||||||
|
{brand ? <input type="hidden" name="id" value={brand.id} /> : null}
|
||||||
|
|
||||||
|
<AdminFormMessage state={state} />
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<AdminField id="name" label={t('fields.name')} error={fieldError('name')}>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={event => {
|
||||||
|
setName(event.target.value)
|
||||||
|
|
||||||
|
if (!slugTouched) {
|
||||||
|
setSlug(slugify(event.target.value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="slug" label={t('fields.slug')} hint={t('hints.slug')} error={fieldError('slug')}>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
value={slug}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={event => {
|
||||||
|
setSlugTouched(true)
|
||||||
|
setSlug(event.target.value)
|
||||||
|
}}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminColorField
|
||||||
|
id="color"
|
||||||
|
name="color"
|
||||||
|
label={t('fields.color')}
|
||||||
|
pickerLabel={t('fields.colorPicker')}
|
||||||
|
value={color}
|
||||||
|
onChange={setColor}
|
||||||
|
hint={t('hints.color')}
|
||||||
|
error={fieldError('color')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminField id="sort" label={t('fields.sort')} hint={t('hints.sort')} error={fieldError('sort')}>
|
||||||
|
<input
|
||||||
|
id="sort"
|
||||||
|
name="sort"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
step={1}
|
||||||
|
value={sort}
|
||||||
|
onChange={event => setSort(event.target.value)}
|
||||||
|
className={`${inputClass} font-mono tabular-nums`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<AdminSubmit pendingLabel={t('saving')} icon={<TbDeviceFloppy className="size-4" />}>
|
||||||
|
{brand ? t('save') : t('create')}
|
||||||
|
</AdminSubmit>
|
||||||
|
<Link href={adminBrandsPath} className={quietLinkClass}>
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbCopy, TbKey } from 'react-icons/tb'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { AdminFormMessage } from './AdminFormMessage'
|
||||||
|
import { AdminSubmit } from './AdminSubmit'
|
||||||
|
import { ghostButtonClass, hintClass, inputClass, labelClass, selectClass } from './styles'
|
||||||
|
import { createApiClient } from '~/lib/admin-actions'
|
||||||
|
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||||
|
|
||||||
|
export type ClientFormProps = {
|
||||||
|
projects: { id: string, name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = ['read', 'write'] as const
|
||||||
|
|
||||||
|
const scopes = ['internal', 'customer', 'public'] as const
|
||||||
|
|
||||||
|
export function ClientForm({ projects }: ClientFormProps) {
|
||||||
|
const t = useTranslations('admin.clients')
|
||||||
|
const errors = useTranslations('admin.errors')
|
||||||
|
const [state, formAction] = useActionState(createApiClient, emptyAdminFormState)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [mode, setMode] = useState<string>('read')
|
||||||
|
const [scope, setScope] = useState<string>('customer')
|
||||||
|
const [projectId, setProjectId] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === 'ok' && state.token) {
|
||||||
|
setName('')
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
}, [state.status, state.token])
|
||||||
|
|
||||||
|
function fieldError(field: string): string | undefined {
|
||||||
|
const key = state.fields?.[field]
|
||||||
|
return key ? errors(key) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken(token: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token)
|
||||||
|
setCopied(true)
|
||||||
|
} catch {
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{state.token ? (
|
||||||
|
<div className="flex flex-col gap-3 border-l-2 border-signal bg-surface px-5 py-4">
|
||||||
|
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
|
||||||
|
{t('tokenTitle')}
|
||||||
|
</span>
|
||||||
|
<code className="block break-all font-mono text-base text-ink">{state.token}</code>
|
||||||
|
<p className="m-0 max-w-read text-small text-pretty text-ink-2">{t('tokenHint')}</p>
|
||||||
|
<span className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={() => copyToken(state.token ?? '')} className={ghostButtonClass}>
|
||||||
|
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{copied ? t('copied') : t('copy')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form action={formAction} className="flex flex-col gap-8">
|
||||||
|
<AdminFormMessage state={state} silentOnDone />
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<AdminField id="name" label={t('fields.name')} hint={t('hints.name')} error={fieldError('name')}>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={event => setName(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="projectId" label={t('fields.project')} hint={t('hints.project')} error={fieldError('projectId')}>
|
||||||
|
<select
|
||||||
|
id="projectId"
|
||||||
|
name="projectId"
|
||||||
|
value={projectId}
|
||||||
|
onChange={event => setProjectId(event.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">{t('allProjects')}</option>
|
||||||
|
{projects.map(project => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="mode" label={t('fields.mode')} hint={t('hints.mode')} error={fieldError('mode')}>
|
||||||
|
<select
|
||||||
|
id="mode"
|
||||||
|
name="mode"
|
||||||
|
value={mode}
|
||||||
|
onChange={event => setMode(event.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{modes.map(value => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(`modes.${value}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="scope" label={t('fields.scope')} hint={t('hints.scope')} error={fieldError('scope')}>
|
||||||
|
<select
|
||||||
|
id="scope"
|
||||||
|
name="scope"
|
||||||
|
value={scope}
|
||||||
|
onChange={event => setScope(event.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{scopes.map(value => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(`scopes.${value}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-rule pt-6">
|
||||||
|
<span className={labelClass}>{t('publishTitle')}</span>
|
||||||
|
<span className={hintClass}>{t('publishHint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<AdminSubmit pendingLabel={t('creating')} icon={<TbKey className="size-4" />}>
|
||||||
|
{t('create')}
|
||||||
|
</AdminSubmit>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbBan } from 'react-icons/tb'
|
||||||
|
import { AdminSubmit } from './AdminSubmit'
|
||||||
|
import { errorClass } from './styles'
|
||||||
|
import { revokeApiClient } from '~/lib/admin-actions'
|
||||||
|
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||||
|
|
||||||
|
export type ClientRevokeButtonProps = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientRevokeButton({ id }: ClientRevokeButtonProps) {
|
||||||
|
const t = useTranslations('admin.clients')
|
||||||
|
const messages = useTranslations('admin.messages')
|
||||||
|
const [state, formAction] = useActionState(revokeApiClient, emptyAdminFormState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-2">
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<AdminSubmit quiet pendingLabel={t('revoking')} icon={<TbBan className="size-4" />}>
|
||||||
|
{t('revoke')}
|
||||||
|
</AdminSubmit>
|
||||||
|
{state.status === 'error' && state.message ? (
|
||||||
|
<span role="alert" className={errorClass}>
|
||||||
|
{messages(state.message)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useId } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { EntryMediaPicker } from './EntryMediaPicker'
|
||||||
|
import { inputClass, selectClass, textareaClass } from './styles'
|
||||||
|
import type { BlockType, BlockValues } from '~/domain/blocks'
|
||||||
|
import type { EntryMedia } from '~/lib/entry-types'
|
||||||
|
|
||||||
|
export type EntryBlockCardProps = {
|
||||||
|
type: BlockType
|
||||||
|
data: BlockValues
|
||||||
|
index: number
|
||||||
|
total: number
|
||||||
|
media: EntryMedia[]
|
||||||
|
accept: string
|
||||||
|
dragging: boolean
|
||||||
|
onChange: (data: BlockValues) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onMove: (to: number) => void
|
||||||
|
onDragStart: () => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onDropOn: () => void
|
||||||
|
upload: (files: File[]) => Promise<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(data: BlockValues, key: string): string {
|
||||||
|
const value = data[key]
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(data: BlockValues, key: string): string[] {
|
||||||
|
const value = data[key]
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryBlockCard({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
media,
|
||||||
|
accept,
|
||||||
|
dragging,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDropOn,
|
||||||
|
upload,
|
||||||
|
}: EntryBlockCardProps) {
|
||||||
|
const t = useTranslations('admin.entries')
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
function set(key: string, value: unknown) {
|
||||||
|
onChange({ ...data, [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(key: string, ids: string[]) {
|
||||||
|
set(key, ids[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadInto(key: string, files: File[]) {
|
||||||
|
const ids = await upload(files)
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
|
set(key, ids[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadIntoList(key: string, files: File[]) {
|
||||||
|
const ids = await upload(files)
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
|
set(key, [...list(data, key), ...ids])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
onDragOver={event => event.preventDefault()}
|
||||||
|
onDrop={event => {
|
||||||
|
if (event.dataTransfer.files.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onDropOn()
|
||||||
|
}}
|
||||||
|
className={`flex flex-col gap-4 border bg-surface px-5 py-4 ${dragging ? 'border-signal' : 'border-rule'}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
aria-label={t('actions.drag')}
|
||||||
|
className="flex cursor-grab items-center text-ink-3 hover:text-ink"
|
||||||
|
>
|
||||||
|
<TbGripVertical aria-hidden="true" className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-micro font-semibold uppercase tracking-label text-ink-2">
|
||||||
|
{t(`blocks.${type}`)}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => onMove(index - 1)}
|
||||||
|
aria-label={t('actions.up')}
|
||||||
|
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<TbArrowUp aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={() => onMove(index + 1)}
|
||||||
|
aria-label={t('actions.down')}
|
||||||
|
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<TbArrowDown aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={t('actions.remove')}
|
||||||
|
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
|
||||||
|
>
|
||||||
|
<TbTrash aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'text' ? (
|
||||||
|
<AdminField id={`${id}-text`} label={t('blockFields.text')}>
|
||||||
|
<textarea
|
||||||
|
id={`${id}-text`}
|
||||||
|
rows={6}
|
||||||
|
value={text(data, 'text')}
|
||||||
|
placeholder={t('blockFields.textPlaceholder')}
|
||||||
|
onChange={event => set('text', event.target.value)}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'image' ? (
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('blockFields.image')}
|
||||||
|
media={media}
|
||||||
|
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => pick('mediaId', ids)}
|
||||||
|
onUpload={files => void uploadInto('mediaId', files)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'gallery' ? (
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('blockFields.gallery')}
|
||||||
|
media={media}
|
||||||
|
selected={list(data, 'mediaIds')}
|
||||||
|
multiple
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => set('mediaIds', ids)}
|
||||||
|
onUpload={files => void uploadIntoList('mediaIds', files)}
|
||||||
|
hint={t('blockFields.galleryHint')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'before_after' ? (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('blockFields.before')}
|
||||||
|
media={media}
|
||||||
|
selected={text(data, 'beforeMediaId') === '' ? [] : [text(data, 'beforeMediaId')]}
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => pick('beforeMediaId', ids)}
|
||||||
|
onUpload={files => void uploadInto('beforeMediaId', files)}
|
||||||
|
/>
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('blockFields.after')}
|
||||||
|
media={media}
|
||||||
|
selected={text(data, 'afterMediaId') === '' ? [] : [text(data, 'afterMediaId')]}
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => pick('afterMediaId', ids)}
|
||||||
|
onUpload={files => void uploadInto('afterMediaId', files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'video' ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('blockFields.video')}
|
||||||
|
media={media}
|
||||||
|
selected={text(data, 'mediaId') === '' ? [] : [text(data, 'mediaId')]}
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => pick('mediaId', ids)}
|
||||||
|
onUpload={files => void uploadInto('mediaId', files)}
|
||||||
|
hint={t('blockFields.videoHint')}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<AdminField id={`${id}-url`} label={t('blockFields.url')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-url`}
|
||||||
|
type="url"
|
||||||
|
value={text(data, 'url')}
|
||||||
|
onChange={event => set('url', event.target.value)}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
<AdminField id={`${id}-title`} label={t('blockFields.label')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-title`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'title')}
|
||||||
|
onChange={event => set('title', event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'quote' ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<AdminField id={`${id}-quote`} label={t('blockFields.quote')}>
|
||||||
|
<textarea
|
||||||
|
id={`${id}-quote`}
|
||||||
|
rows={3}
|
||||||
|
value={text(data, 'text')}
|
||||||
|
onChange={event => set('text', event.target.value)}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
<AdminField id={`${id}-source`} label={t('blockFields.source')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-source`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'source')}
|
||||||
|
onChange={event => set('source', event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'code' ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<AdminField id={`${id}-language`} label={t('blockFields.language')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-language`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'language')}
|
||||||
|
onChange={event => set('language', event.target.value)}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
<AdminField id={`${id}-code`} label={t('blockFields.code')}>
|
||||||
|
<textarea
|
||||||
|
id={`${id}-code`}
|
||||||
|
rows={8}
|
||||||
|
spellCheck={false}
|
||||||
|
value={text(data, 'code')}
|
||||||
|
onChange={event => set('code', event.target.value)}
|
||||||
|
className={`${textareaClass} font-mono text-small`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'link' ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<AdminField id={`${id}-linkurl`} label={t('blockFields.url')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-linkurl`}
|
||||||
|
type="url"
|
||||||
|
value={text(data, 'url')}
|
||||||
|
onChange={event => set('url', event.target.value)}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
<AdminField id={`${id}-linklabel`} label={t('blockFields.label')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-linklabel`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'label')}
|
||||||
|
onChange={event => set('label', event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
<AdminField id={`${id}-linkdescription`} label={t('blockFields.description')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-linkdescription`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'description')}
|
||||||
|
onChange={event => set('description', event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'callout' ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<AdminField id={`${id}-callouttitle`} label={t('blockFields.label')}>
|
||||||
|
<input
|
||||||
|
id={`${id}-callouttitle`}
|
||||||
|
type="text"
|
||||||
|
value={text(data, 'title')}
|
||||||
|
onChange={event => set('title', event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
<AdminField id={`${id}-tone`} label={t('blockFields.tone')}>
|
||||||
|
<select
|
||||||
|
id={`${id}-tone`}
|
||||||
|
value={text(data, 'tone') === 'warning' ? 'warning' : 'info'}
|
||||||
|
onChange={event => set('tone', event.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="info">{t('blockFields.toneInfo')}</option>
|
||||||
|
<option value="warning">{t('blockFields.toneWarning')}</option>
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
<AdminField id={`${id}-callouttext`} label={t('blockFields.text')}>
|
||||||
|
<textarea
|
||||||
|
id={`${id}-callouttext`}
|
||||||
|
rows={3}
|
||||||
|
value={text(data, 'text')}
|
||||||
|
onChange={event => set('text', event.target.value)}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import {
|
||||||
|
TbArchive,
|
||||||
|
TbCalendarClock,
|
||||||
|
TbCalendarX,
|
||||||
|
TbDeviceFloppy,
|
||||||
|
TbEye,
|
||||||
|
TbPencil,
|
||||||
|
TbPhotoPlus,
|
||||||
|
TbPlus,
|
||||||
|
TbRotate2,
|
||||||
|
TbSend,
|
||||||
|
TbX,
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { AdminNotice } from './AdminNotice'
|
||||||
|
import { EntryBlockCard } from './EntryBlockCard'
|
||||||
|
import { EntryMediaPicker } from './EntryMediaPicker'
|
||||||
|
import { EntryPreview } from './EntryPreview'
|
||||||
|
import { EntryStatusChip } from './EntryStatusChip'
|
||||||
|
import {
|
||||||
|
ghostButtonClass,
|
||||||
|
hintClass,
|
||||||
|
inputClass,
|
||||||
|
labelClass,
|
||||||
|
metaClass,
|
||||||
|
primaryButtonClass,
|
||||||
|
quietLinkClass,
|
||||||
|
selectClass,
|
||||||
|
textareaClass,
|
||||||
|
} from './styles'
|
||||||
|
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
|
||||||
|
import { checkPublish } from '~/domain/publish-checks'
|
||||||
|
import { defaultPostTypeColor } from '~/domain/post-type'
|
||||||
|
import { slugify } from '~/domain/slug'
|
||||||
|
import type { Audience, PostStatus } from '~/domain/types'
|
||||||
|
import { adminEntriesPath, adminEntryPath } from '~/lib/admin-routes'
|
||||||
|
import {
|
||||||
|
archiveEntry,
|
||||||
|
loadEntryMedia,
|
||||||
|
publishEntry,
|
||||||
|
resumeEntry,
|
||||||
|
saveEntry,
|
||||||
|
scheduleEntry,
|
||||||
|
unscheduleEntry,
|
||||||
|
uploadEntryImage,
|
||||||
|
} from '~/lib/entry-actions'
|
||||||
|
import type { EntryErrorKey, EntryInput, EntryMedia, EntryResult, EntryState, EntryTypeOption } from '~/lib/entry-types'
|
||||||
|
|
||||||
|
export type EntryEditorProject = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntryEditorEntry = {
|
||||||
|
id: string
|
||||||
|
number: number
|
||||||
|
slug: string
|
||||||
|
status: PostStatus
|
||||||
|
publishAt: string | null
|
||||||
|
updatedLabel: string
|
||||||
|
projectId: string
|
||||||
|
title: string
|
||||||
|
teaser: string
|
||||||
|
typeId: string
|
||||||
|
audience: Audience
|
||||||
|
coverMediaId: string | null
|
||||||
|
blocks: { type: BlockType, data: BlockValues }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntryEditorProps = {
|
||||||
|
projects: EntryEditorProject[]
|
||||||
|
types: EntryTypeOption[]
|
||||||
|
media: EntryMedia[]
|
||||||
|
accept: string
|
||||||
|
defaultProjectId: string
|
||||||
|
entry?: EntryEditorEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorBlock = {
|
||||||
|
key: string
|
||||||
|
type: BlockType
|
||||||
|
data: BlockValues
|
||||||
|
}
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
projectId: string
|
||||||
|
title: string
|
||||||
|
teaser: string
|
||||||
|
slug: string
|
||||||
|
typeId: string
|
||||||
|
audience: Audience
|
||||||
|
cover: string | null
|
||||||
|
blocks: EditorBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Upload = {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'done' | 'failed'
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const audiences: Audience[] = ['internal', 'customer', 'public']
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
function nextKey(): string {
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return `block-${counter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialForm(entry: EntryEditorEntry | undefined, defaultProjectId: string, defaultTypeId: string): Form {
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
projectId: defaultProjectId,
|
||||||
|
title: '',
|
||||||
|
teaser: '',
|
||||||
|
slug: '',
|
||||||
|
typeId: defaultTypeId,
|
||||||
|
audience: 'internal',
|
||||||
|
cover: null,
|
||||||
|
blocks: [{ key: nextKey(), type: 'text', data: emptyBlockData('text') }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: entry.projectId,
|
||||||
|
title: entry.title,
|
||||||
|
teaser: entry.teaser,
|
||||||
|
slug: entry.slug,
|
||||||
|
typeId: entry.typeId,
|
||||||
|
audience: entry.audience,
|
||||||
|
cover: entry.coverMediaId,
|
||||||
|
blocks: entry.blocks.map(block => ({ key: nextKey(), type: block.type, data: block.data })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadOf(form: Form): EntryInput {
|
||||||
|
return {
|
||||||
|
projectId: form.projectId,
|
||||||
|
title: form.title,
|
||||||
|
teaser: form.teaser,
|
||||||
|
slug: form.slug.trim() === '' ? undefined : form.slug.trim(),
|
||||||
|
typeId: form.typeId,
|
||||||
|
audience: form.audience,
|
||||||
|
coverMediaId: form.cover,
|
||||||
|
blocks: form.blocks.map(block => ({ type: block.type, data: block.data })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) {
|
||||||
|
const t = useTranslations('admin.entries')
|
||||||
|
const [form, setForm] = useState<Form>(() => initialForm(entry, defaultProjectId, types[0]?.id ?? ''))
|
||||||
|
const [media, setMedia] = useState<EntryMedia[]>(initialMedia)
|
||||||
|
const [status, setStatus] = useState<EntryState | null>(
|
||||||
|
entry
|
||||||
|
? {
|
||||||
|
id: entry.id,
|
||||||
|
number: entry.number,
|
||||||
|
slug: entry.slug,
|
||||||
|
status: entry.status,
|
||||||
|
publishAt: entry.publishAt,
|
||||||
|
updatedAt: '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
const [savedAt, setSavedAt] = useState<Date | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<EntryErrorKey | null>(null)
|
||||||
|
const [blockers, setBlockers] = useState<string[]>([])
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
const [scope, setScope] = useState<Audience>('customer')
|
||||||
|
const [when, setWhen] = useState('')
|
||||||
|
const [uploads, setUploads] = useState<Upload[]>([])
|
||||||
|
const [dropping, setDropping] = useState(false)
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
const key = useMemo(() => JSON.stringify(payloadOf(form)), [form])
|
||||||
|
const formRef = useRef(form)
|
||||||
|
const keyRef = useRef(key)
|
||||||
|
const idRef = useRef(entry?.id ?? '')
|
||||||
|
const savingRef = useRef(false)
|
||||||
|
const savedKey = useRef<string | null>(null)
|
||||||
|
|
||||||
|
formRef.current = form
|
||||||
|
keyRef.current = key
|
||||||
|
|
||||||
|
if (savedKey.current === null) {
|
||||||
|
savedKey.current = entry ? key : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = projects.find(item => item.id === form.projectId) ?? projects[0]
|
||||||
|
const selectedType = types.find(item => item.id === form.typeId) ?? types[0]
|
||||||
|
const touched = entry !== undefined || status !== null || form.title.trim() !== ''
|
||||||
|
const dirty = touched && key !== savedKey.current
|
||||||
|
|
||||||
|
const checks = useMemo(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
|
||||||
|
for (const block of form.blocks) {
|
||||||
|
for (const field of ['mediaId', 'beforeMediaId', 'afterMediaId']) {
|
||||||
|
const value = block.data[field]
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value !== '') {
|
||||||
|
ids.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const many = block.data.mediaIds
|
||||||
|
|
||||||
|
if (Array.isArray(many)) {
|
||||||
|
for (const value of many) {
|
||||||
|
if (typeof value === 'string' && value !== '') {
|
||||||
|
ids.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.cover) {
|
||||||
|
ids.add(form.cover)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPublish({
|
||||||
|
title: form.title,
|
||||||
|
audience: form.audience,
|
||||||
|
blockCount: form.blocks.filter(block => !isBlockEmpty({ type: block.type, data: block.data })).length,
|
||||||
|
images: [...ids].map(id => ({ hasAlt: (media.find(item => item.id === id)?.alt ?? '').trim() !== '' })),
|
||||||
|
tagCount: 0,
|
||||||
|
hasCover: form.cover !== null,
|
||||||
|
})
|
||||||
|
}, [form, media])
|
||||||
|
|
||||||
|
const persist = useCallback(async (): Promise<EntryState | null> => {
|
||||||
|
const current = formRef.current
|
||||||
|
|
||||||
|
if (current.title.trim() === '' || savingRef.current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = keyRef.current
|
||||||
|
|
||||||
|
if (snapshot === savedKey.current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
savingRef.current = true
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const created = idRef.current === ''
|
||||||
|
const result = await saveEntry({ ...payloadOf(current), id: idRef.current === '' ? undefined : idRef.current })
|
||||||
|
|
||||||
|
savingRef.current = false
|
||||||
|
setSaving(false)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
savedKey.current = snapshot
|
||||||
|
idRef.current = result.entry.id
|
||||||
|
setStatus(result.entry)
|
||||||
|
setSavedAt(new Date())
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
window.history.replaceState(null, '', adminEntryPath(result.entry.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyRef.current !== savedKey.current) {
|
||||||
|
return persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.entry
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (form.title.trim() === '' || key === savedKey.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => void persist(), 1400)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [key, form.title, persist])
|
||||||
|
|
||||||
|
const upload = useCallback(async (files: File[]): Promise<string[]> => {
|
||||||
|
const projectId = formRef.current.projectId
|
||||||
|
const accepted = files.filter(file => file.type.startsWith('image/'))
|
||||||
|
const done: string[] = []
|
||||||
|
|
||||||
|
for (const file of accepted) {
|
||||||
|
const mark = `upload-${Date.now()}-${done.length}-${file.name}`
|
||||||
|
|
||||||
|
setUploads(current => [...current, { key: mark, name: file.name, status: 'pending' }])
|
||||||
|
|
||||||
|
const data = new FormData()
|
||||||
|
|
||||||
|
data.set('projectId', projectId)
|
||||||
|
data.set('file', file)
|
||||||
|
|
||||||
|
const result = await uploadEntryImage(data)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setMedia(current => [result.media, ...current])
|
||||||
|
setUploads(current => current.map(item => (item.key === mark ? { ...item, status: 'done' } : item)))
|
||||||
|
done.push(result.media.id)
|
||||||
|
} else {
|
||||||
|
setUploads(current =>
|
||||||
|
current.map(item => (item.key === mark ? { ...item, status: 'failed', reason: result.error } : item)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return done
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const insertFiles = useCallback(async (files: File[]) => {
|
||||||
|
const ids = await upload(files)
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const block: EditorBlock = ids.length === 1
|
||||||
|
? { key: nextKey(), type: 'image', data: { mediaId: ids[0]! } }
|
||||||
|
: { key: nextKey(), type: 'gallery', data: { mediaIds: ids } }
|
||||||
|
|
||||||
|
setForm(current => {
|
||||||
|
const single = current.blocks[0]
|
||||||
|
const base = current.blocks.length === 1 && single && isBlockEmpty({ type: single.type, data: single.data })
|
||||||
|
? []
|
||||||
|
: current.blocks
|
||||||
|
|
||||||
|
return { ...current, cover: current.cover ?? ids[0]!, blocks: [...base, block] }
|
||||||
|
})
|
||||||
|
}, [upload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPaste(event: ClipboardEvent) {
|
||||||
|
const files = [...(event.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
void insertFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('paste', onPaste)
|
||||||
|
|
||||||
|
return () => document.removeEventListener('paste', onPaste)
|
||||||
|
}, [insertFiles])
|
||||||
|
|
||||||
|
async function run(action: (id: string) => Promise<EntryResult>) {
|
||||||
|
setBusy(true)
|
||||||
|
setBlockers([])
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
await persist()
|
||||||
|
|
||||||
|
const id = idRef.current
|
||||||
|
|
||||||
|
if (id === '') {
|
||||||
|
setBusy(false)
|
||||||
|
setError('titleMissing')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await action(id)
|
||||||
|
|
||||||
|
setBusy(false)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error)
|
||||||
|
setBlockers(result.blockers ?? [])
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(result.entry)
|
||||||
|
setSavedAt(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch: Partial<Form>) {
|
||||||
|
setForm(current => ({ ...current, ...patch }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeProject(projectId: string) {
|
||||||
|
update({ projectId, cover: null })
|
||||||
|
setMedia([])
|
||||||
|
|
||||||
|
void loadEntryMedia(projectId).then(result => {
|
||||||
|
if (result.ok) {
|
||||||
|
setMedia(result.items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBlock(type: BlockType) {
|
||||||
|
setAdding(false)
|
||||||
|
setForm(current => ({
|
||||||
|
...current,
|
||||||
|
blocks: [...current.blocks, { key: nextKey(), type, data: emptyBlockData(type) }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBlock(from: number, to: number) {
|
||||||
|
if (to < 0 || from === to) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm(current => {
|
||||||
|
if (to >= current.blocks.length) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [...current.blocks]
|
||||||
|
const [moved] = blocks.splice(from, 1)
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.splice(to, 0, moved)
|
||||||
|
|
||||||
|
return { ...current, blocks }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLabel = saving
|
||||||
|
? t('save.pending')
|
||||||
|
: dirty
|
||||||
|
? t('save.dirty')
|
||||||
|
: savedAt
|
||||||
|
? t('save.saved', { time: localTime(savedAt) })
|
||||||
|
: entry
|
||||||
|
? t('save.stored', { time: entry.updatedLabel })
|
||||||
|
: t('save.idle')
|
||||||
|
|
||||||
|
const current = status?.status ?? 'draft'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-rule bg-paper py-3">
|
||||||
|
<Link href={adminEntriesPath} className={quietLinkClass}>
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<EntryStatusChip status={current} label={t(`status.${current}`)} />
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<span className={metaClass}>
|
||||||
|
{project ? `${project.code}-${String(status.number).padStart(4, '0')}` : status.number}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className={metaClass}>{saveLabel}</span>
|
||||||
|
|
||||||
|
<span aria-hidden="true" className="h-px min-w-6 flex-1 bg-rule" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreview(value => !value)}
|
||||||
|
className={ghostButtonClass}
|
||||||
|
>
|
||||||
|
{preview ? <TbPencil aria-hidden="true" className="size-4" /> : <TbEye aria-hidden="true" className="size-4" />}
|
||||||
|
{preview ? t('actions.edit') : t('actions.preview')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !dirty || form.title.trim() === ''}
|
||||||
|
onClick={() => void persist()}
|
||||||
|
className={ghostButtonClass}
|
||||||
|
>
|
||||||
|
<TbDeviceFloppy aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.save')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{current === 'published' ? (
|
||||||
|
<button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}>
|
||||||
|
<TbArchive aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.archive')}
|
||||||
|
</button>
|
||||||
|
) : current === 'archived' ? (
|
||||||
|
<button type="button" disabled={busy} onClick={() => void run(id => resumeEntry({ id }))} className={ghostButtonClass}>
|
||||||
|
<TbRotate2 aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.resume')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || form.title.trim() === ''}
|
||||||
|
onClick={() => void run(id => publishEntry({ id }))}
|
||||||
|
className={primaryButtonClass}
|
||||||
|
>
|
||||||
|
<TbSend aria-hidden="true" className="size-4" />
|
||||||
|
{busy ? t('actions.publishing') : t('actions.publish')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <AdminNotice tone="alert">{t(`errors.${error}`)}</AdminNotice> : null}
|
||||||
|
|
||||||
|
{blockers.length > 0 ? (
|
||||||
|
<AdminNotice tone="alert" title={t('checks.blockersTitle')}>
|
||||||
|
{blockers.map(entry => t(`checks.${entry}`)).join(' ')}
|
||||||
|
</AdminNotice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<EntryPreview
|
||||||
|
title={form.title}
|
||||||
|
teaser={form.teaser}
|
||||||
|
typeLabel={selectedType?.label ?? ''}
|
||||||
|
typeColor={selectedType?.color ?? defaultPostTypeColor}
|
||||||
|
audience={form.audience}
|
||||||
|
status={current}
|
||||||
|
number={status?.number ?? null}
|
||||||
|
code={project?.code ?? ''}
|
||||||
|
color={project?.color ?? '#000000'}
|
||||||
|
media={media}
|
||||||
|
cover={form.cover}
|
||||||
|
blocks={form.blocks}
|
||||||
|
scope={scope}
|
||||||
|
onScope={setScope}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onDragOver={event => {
|
||||||
|
if (event.dataTransfer.types.includes('Files')) {
|
||||||
|
event.preventDefault()
|
||||||
|
setDropping(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropping(false)}
|
||||||
|
onDrop={event => {
|
||||||
|
const files = [...event.dataTransfer.files]
|
||||||
|
|
||||||
|
setDropping(false)
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
void insertFiles(files)
|
||||||
|
}}
|
||||||
|
className={`flex flex-col gap-8 border border-dashed px-1 py-1 ${dropping ? 'border-signal' : 'border-transparent'}`}
|
||||||
|
>
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
|
||||||
|
<div className="flex min-w-0 flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label htmlFor="entry-title" className="sr-only">
|
||||||
|
{t('fields.title')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entry-title"
|
||||||
|
value={form.title}
|
||||||
|
autoFocus={!entry}
|
||||||
|
placeholder={t('fields.titlePlaceholder')}
|
||||||
|
onChange={event => update({ title: event.target.value })}
|
||||||
|
className="w-full min-w-0 border-0 bg-transparent p-0 font-display text-title font-bold text-ink placeholder:text-ink-3 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminField id="entry-teaser" label={t('fields.teaser')} hint={t('hints.teaser')}>
|
||||||
|
<textarea
|
||||||
|
id="entry-teaser"
|
||||||
|
rows={2}
|
||||||
|
value={form.teaser}
|
||||||
|
placeholder={t('fields.teaserPlaceholder')}
|
||||||
|
onChange={event => update({ teaser: event.target.value })}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
{uploads.length > 0 ? (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{uploads.map(item => (
|
||||||
|
<li key={item.key} className={metaClass}>
|
||||||
|
{item.status === 'pending'
|
||||||
|
? t('uploads.pending', { name: item.name })
|
||||||
|
: item.status === 'done'
|
||||||
|
? t('uploads.done', { name: item.name })
|
||||||
|
: t('uploads.failed', { name: item.name, reason: t(`errors.${item.reason ?? 'unknown'}`) })}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-4 p-0">
|
||||||
|
{form.blocks.map((block, index) => (
|
||||||
|
<EntryBlockCard
|
||||||
|
key={block.key}
|
||||||
|
type={block.type}
|
||||||
|
data={block.data}
|
||||||
|
index={index}
|
||||||
|
total={form.blocks.length}
|
||||||
|
media={media}
|
||||||
|
accept={accept}
|
||||||
|
dragging={dragIndex === index}
|
||||||
|
onChange={data =>
|
||||||
|
setForm(state => ({
|
||||||
|
...state,
|
||||||
|
blocks: state.blocks.map(item => (item.key === block.key ? { ...item, data } : item)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onRemove={() =>
|
||||||
|
setForm(state => ({ ...state, blocks: state.blocks.filter(item => item.key !== block.key) }))
|
||||||
|
}
|
||||||
|
onMove={to => moveBlock(index, to)}
|
||||||
|
onDragStart={() => setDragIndex(index)}
|
||||||
|
onDragEnd={() => setDragIndex(null)}
|
||||||
|
onDropOn={() => {
|
||||||
|
if (dragIndex !== null) {
|
||||||
|
moveBlock(dragIndex, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragIndex(null)
|
||||||
|
}}
|
||||||
|
upload={upload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<button type="button" onClick={() => setAdding(value => !value)} className={ghostButtonClass}>
|
||||||
|
{adding ? <TbX aria-hidden="true" className="size-4" /> : <TbPlus aria-hidden="true" className="size-4" />}
|
||||||
|
{adding ? t('actions.close') : t('actions.addBlock')}
|
||||||
|
</button>
|
||||||
|
<span className={hintClass}>
|
||||||
|
<TbPhotoPlus aria-hidden="true" className="mr-2 inline size-4 align-text-bottom" />
|
||||||
|
{t('hints.drop')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding ? (
|
||||||
|
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
|
||||||
|
{blockTypes.map(type => (
|
||||||
|
<li key={type}>
|
||||||
|
<button type="button" onClick={() => addBlock(type)} className={ghostButtonClass}>
|
||||||
|
{t(`blocks.${type}`)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<aside className="flex min-w-0 flex-col gap-6 lg:sticky lg:top-20">
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<AdminField
|
||||||
|
id="entry-project"
|
||||||
|
label={t('fields.project')}
|
||||||
|
hint={entry ? t('hints.projectMove') : undefined}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="entry-project"
|
||||||
|
value={form.projectId}
|
||||||
|
onChange={event => changeProject(event.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{projects.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="entry-type" label={t('fields.type')}>
|
||||||
|
<select
|
||||||
|
id="entry-type"
|
||||||
|
value={form.typeId}
|
||||||
|
onChange={event => update({ typeId: event.target.value })}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{types.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="entry-audience" label={t('fields.audience')} hint={t('hints.audience')}>
|
||||||
|
<select
|
||||||
|
id="entry-audience"
|
||||||
|
value={form.audience}
|
||||||
|
onChange={event => update({ audience: event.target.value as Audience })}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{audiences.map(item => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{t(`audience.${item}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 border-t border-rule pt-6">
|
||||||
|
<EntryMediaPicker
|
||||||
|
label={t('fields.cover')}
|
||||||
|
media={media}
|
||||||
|
selected={form.cover ? [form.cover] : []}
|
||||||
|
accept={accept}
|
||||||
|
onSelect={ids => update({ cover: ids[0] ?? null })}
|
||||||
|
onUpload={files => void upload(files).then(ids => ids[0] && update({ cover: ids[0] }))}
|
||||||
|
hint={t('hints.cover')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<AdminField id="entry-slug" label={t('fields.slug')} hint={t('hints.slug')}>
|
||||||
|
<input
|
||||||
|
id="entry-slug"
|
||||||
|
value={form.slug}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={slugify(form.title)}
|
||||||
|
onChange={event => update({ slug: event.target.value })}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
<span className={labelClass}>{t('schedule.title')}</span>
|
||||||
|
|
||||||
|
{current === 'scheduled' && status?.publishAt ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<span suppressHydrationWarning className={metaClass}>
|
||||||
|
{new Date(status.publishAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void run(id => unscheduleEntry({ id }))}
|
||||||
|
className={ghostButtonClass}
|
||||||
|
>
|
||||||
|
<TbCalendarX aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.unschedule')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<input
|
||||||
|
id="entry-schedule"
|
||||||
|
type="datetime-local"
|
||||||
|
value={when}
|
||||||
|
onChange={event => setWhen(event.target.value)}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || when === ''}
|
||||||
|
onClick={() => void run(id => scheduleEntry({ id, publishAt: when }))}
|
||||||
|
className={ghostButtonClass}
|
||||||
|
>
|
||||||
|
<TbCalendarClock aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.schedule')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={hintClass}>{t('hints.schedule')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checks.hints.length > 0 || checks.blockers.length > 0 ? (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{checks.blockers.map(item => (
|
||||||
|
<li key={item} className="font-mono text-micro text-signal">
|
||||||
|
{t(`checks.${item}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{checks.hints.map(item => (
|
||||||
|
<li key={item} className={metaClass}>
|
||||||
|
{t(`checks.${item}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div> </aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbSearch } from 'react-icons/tb'
|
||||||
|
import { AdminField } from './AdminField'
|
||||||
|
import { ghostButtonClass, inputClass, quietLinkClass, selectClass } from './styles'
|
||||||
|
import { postStatuses } from '~/domain/post-status'
|
||||||
|
import { adminEntriesPath } from '~/lib/admin-routes'
|
||||||
|
import type { EntryTypeOption } from '~/lib/entry-types'
|
||||||
|
|
||||||
|
export type EntryFiltersProps = {
|
||||||
|
projects: { slug: string, name: string }[]
|
||||||
|
types: EntryTypeOption[]
|
||||||
|
project: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
search: string
|
||||||
|
filtered: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryFilters({ projects, types, project, status, type, search, filtered }: EntryFiltersProps) {
|
||||||
|
const t = useTranslations('admin.entries.filters')
|
||||||
|
const states = useTranslations('admin.entries.status')
|
||||||
|
const form = useRef<HTMLFormElement>(null)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form.current?.requestSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={form}
|
||||||
|
action={adminEntriesPath}
|
||||||
|
className="flex flex-col gap-5 border border-rule bg-surface px-5 py-4"
|
||||||
|
>
|
||||||
|
<div className="grid gap-5 md:grid-cols-4">
|
||||||
|
<AdminField id="filter-project" label={t('project')}>
|
||||||
|
<select
|
||||||
|
id="filter-project"
|
||||||
|
name="project"
|
||||||
|
defaultValue={project}
|
||||||
|
onChange={submit}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">{t('allProjects')}</option>
|
||||||
|
{projects.map(item => (
|
||||||
|
<option key={item.slug} value={item.slug}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="filter-status" label={t('status')}>
|
||||||
|
<select id="filter-status" name="status" defaultValue={status} onChange={submit} className={selectClass}>
|
||||||
|
<option value="">{t('allStatus')}</option>
|
||||||
|
{postStatuses.map(item => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{states(item)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="filter-type" label={t('type')}>
|
||||||
|
<select id="filter-type" name="type" defaultValue={type} onChange={submit} className={selectClass}>
|
||||||
|
<option value="">{t('allTypes')}</option>
|
||||||
|
{types.map(item => (
|
||||||
|
<option key={item.id} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
|
<AdminField id="filter-search" label={t('search')}>
|
||||||
|
<input
|
||||||
|
id="filter-search"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={search}
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<button type="submit" className={ghostButtonClass}>
|
||||||
|
<TbSearch aria-hidden="true" className="size-4" />
|
||||||
|
{t('submit')}
|
||||||
|
</button>
|
||||||
|
{filtered ? (
|
||||||
|
<Link href={adminEntriesPath} className={quietLinkClass}>
|
||||||
|
{t('reset')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useId, useState } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbAlertTriangle, TbLibraryPhoto, TbTrash, TbUpload, TbX } from 'react-icons/tb'
|
||||||
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
|
import { ghostButtonClass, hintClass, labelClass, metaClass } from './styles'
|
||||||
|
import type { EntryMedia } from '~/lib/entry-types'
|
||||||
|
|
||||||
|
export type EntryMediaPickerProps = {
|
||||||
|
label: string
|
||||||
|
media: EntryMedia[]
|
||||||
|
selected: string[]
|
||||||
|
multiple?: boolean
|
||||||
|
accept: string
|
||||||
|
onSelect: (ids: string[]) => void
|
||||||
|
onUpload: (files: File[]) => void
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryMediaPicker({
|
||||||
|
label,
|
||||||
|
media,
|
||||||
|
selected,
|
||||||
|
multiple = false,
|
||||||
|
accept,
|
||||||
|
onSelect,
|
||||||
|
onUpload,
|
||||||
|
hint,
|
||||||
|
}: EntryMediaPickerProps) {
|
||||||
|
const t = useTranslations('admin.entries')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const inputId = useId()
|
||||||
|
const chosen = selected
|
||||||
|
.map(id => media.find(item => item.id === id))
|
||||||
|
.filter((item): item is EntryMedia => item !== undefined)
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
if (multiple) {
|
||||||
|
onSelect(selected.includes(id) ? selected.filter(entry => entry !== id) : [...selected, id])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(selected[0] === id ? [] : [id])
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
<span className={labelClass}>{label}</span>
|
||||||
|
|
||||||
|
{chosen.length > 0 ? (
|
||||||
|
<ul className="m-0 flex list-none flex-wrap gap-3 p-0">
|
||||||
|
{chosen.map(item => (
|
||||||
|
<li key={item.id} className="flex w-40 min-w-0 flex-col gap-1">
|
||||||
|
<span className="relative block aspect-video w-full overflow-hidden border border-rule bg-paper">
|
||||||
|
<Image src={item.thumbnail} alt={item.alt} fill sizes="10rem" className="object-cover" />
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-micro text-ink-3">{item.filename}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(selected.filter(entry => entry !== item.id))}
|
||||||
|
aria-label={t('actions.clear')}
|
||||||
|
className="cursor-pointer border-0 bg-transparent p-0 text-ink-3 hover:text-signal"
|
||||||
|
>
|
||||||
|
<TbTrash aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{item.alt.trim() === '' ? (
|
||||||
|
<span className="flex items-center gap-1 font-mono text-micro text-caution">
|
||||||
|
<TbAlertTriangle aria-hidden="true" className="size-3 shrink-0" />
|
||||||
|
{t('media.altMissing')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={() => setOpen(value => !value)} className={ghostButtonClass}>
|
||||||
|
{open ? <TbX aria-hidden="true" className="size-4" /> : <TbLibraryPhoto aria-hidden="true" className="size-4" />}
|
||||||
|
{open ? t('actions.close') : chosen.length > 0 ? t('actions.change') : t('actions.choose')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label htmlFor={inputId} className={ghostButtonClass}>
|
||||||
|
<TbUpload aria-hidden="true" className="size-4" />
|
||||||
|
{t('actions.upload')}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={event => {
|
||||||
|
const files = [...(event.target.files ?? [])]
|
||||||
|
event.target.value = ''
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
onUpload(files)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint ? <span className={hintClass}>{hint}</span> : null}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
media.length === 0 ? (
|
||||||
|
<p className={`m-0 border border-rule bg-surface px-4 py-3 ${hintClass}`}>{t('media.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<Scroller className="max-h-96 border border-rule bg-surface">
|
||||||
|
<ul className="m-0 grid list-none grid-cols-2 gap-3 p-3 md:grid-cols-4">
|
||||||
|
{media.map(item => {
|
||||||
|
const active = selected.includes(item.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(item.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`flex w-full cursor-pointer flex-col gap-1 border bg-paper p-1 text-left ${
|
||||||
|
active ? 'border-signal' : 'border-rule hover:border-ink-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="relative block aspect-video w-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={item.thumbnail}
|
||||||
|
alt={item.alt}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 46rem) 12rem, 45vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-mono text-micro text-ink-2">{item.filename}</span>
|
||||||
|
<span className={metaClass}>
|
||||||
|
{item.alt.trim() === '' ? t('media.altMissing') : `${item.width} x ${item.height}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Scroller>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user