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:
+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
|
||||
Reference in New Issue
Block a user