5 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

Vier neue Ende-zu-Ende-Tests: Reihenfolge und Mehrfachauswahl ueber die API,
Mehrfachauswahl in der Oberflaeche, der Aenderungsverlauf und das Manifest
samt Symbolen ohne Anmeldung. Damit sind es 21.

Der Test zum Ziehen von Aufgaben war gelegentlich rot, ohne dass etwas kaputt
war: das Neuladen brach den noch laufenden Speicheraufruf ab. Er wartet jetzt
auf die Antwort, statt auf eine feste Pause zu hoffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:44 +02:00
florian.wildeandClaude Opus 5 16e8d31277 feat(ui): Aenderungsverlauf hinter der Versionsnummer
Ein Klick auf die Version in der Fusszeile zeigt, was in den bisherigen
Fassungen dazugekommen ist, was besser wurde und welche Fehler behoben sind.
Kein eigener Menuepunkt: gesucht wird so etwas dort, wo die Version steht.

Die Liste steht in lib/changelog.ts und nicht in einer CHANGELOG.md. Sie
wird in der Anwendung angezeigt, und zwei Quellen liefen prompt auseinander
- wie es der Versionsnummer schon einmal passiert ist, die eine Zeit lang in
der Fusszeile und in der package.json verschiedene Werte zeigte. Ein Test
haelt den obersten Eintrag und die package.json seitdem zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 d97b6f650a feat(pwa): als App installierbar
Web-Manifest mit eigenen Symbolen; auf dem Handy laesst sich Nebula zum
Startbildschirm hinzufuegen und laeuft dann ohne Adressleiste.

Das Manifest entsteht wie die uebrigen Metadaten aus einer Funktion, nicht
aus einer Konstanten: nur so wird APP_NAME zur Laufzeit gelesen und das
fertige Docker-Image bleibt umbenennbar.

Manifest und Symbole muessen an der Zugangsschranke vorbei - der Browser
holt sie, bevor sich jemand angemeldet hat. Sonst landeten sie auf der
Anmeldeseite und die Anwendung waere nicht installierbar.

Die maskierbare Fassung hat mehr Rand: Android schneidet Symbole je nach
Geraet rund, eckig oder als Tropfen zu. Die PNGs sind aus dem vorhandenen
SVG mit Chrome erzeugt - das spart eine Bildbibliothek als Abhaengigkeit.

Beim Ergaenzen des Apple-Symbols ist mir aufgefallen, dass ein eigener
icons-Eintrag den Verweis ersetzt, den Next aus app/icon.svg von selbst
erzeugt: die Seite hatte danach gar kein Symbol mehr und der Browser fiel
auf /favicon.ico zurueck, das es hier nicht gibt. Beide Eintraege stehen
jetzt ausdruecklich da, ein Test prueft den Verweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 49e253f70b feat(board): Mehrfachauswahl fuer Status, Tags und Loeschen
Kaestchen auf jeder Karte, darunter eine Leiste am unteren Rand. Fest
verankert, weil sie bei einer langen Liste oben laengst aus dem Blick
gescrollt waere, waehrend man noch auswaehlt.

Ein eigener Endpunkt statt vieler einzelner PATCHes: eine Transaktion statt
zwanzig Rundgaengen, und der Verlauf bekommt je Projekt genau einen Eintrag
statt eines Schwalls.

Was der Filter ausblendet, faellt aus der Auswahl - sonst traefe eine
Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. Fremde IDs
sammelt der Server wegen der Besitzerbedingung erst gar nicht ein und meldet
sie als uebergangen.

Das Kaestchen ist gedaempft, aber immer sichtbar. Erst beim Ueberfahren
einzublenden waere auf einem Touchgeraet nicht erreichbar - und wer es nie
sieht, weiss nicht, dass es die Auswahl gibt.

Ansicht, Sortierung und Archivschalter ueberdauern nun das Neuladen. Wer sein
Brett von Hand anordnet, will es beim naechsten Aufruf auch sehen. Der
Suchbegriff bleibt aussen vor: eine Suche von gestern wiederherzustellen
waere eher verwirrend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
31 changed files with 1671 additions and 100 deletions
+26 -2
View File
@@ -138,6 +138,15 @@ Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung. wäre Verschwendung.
### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Kanban ### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -147,8 +156,15 @@ erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
normaler Link. normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren. - Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten. Auf Touchgeräten nach kurzem Halten.
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren - **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut. wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick ### Projektkarte auf einen Blick
@@ -461,6 +477,14 @@ letzten Zustellung stehen an der Karte.
Fokusring, `prefers-reduced-motion` schaltet Animationen ab. Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
--- ---
+2 -9
View File
@@ -28,11 +28,6 @@ Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte. Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.2 Kanban mit freier Reihenfolge `M`
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
--- ---
## 3 · Größere Brocken ## 3 · Größere Brocken
@@ -42,7 +37,7 @@ Frei sortieren hieße, eine Position je Projekt zu speichern.
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.4 Mehrsprachigkeit `L` ### 3.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst. Voraussetzung, falls du das Projekt veröffentlichen willst.
@@ -54,14 +49,11 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste | | Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
@@ -101,6 +93,7 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ | | **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ | | **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | | **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+95
View File
@@ -279,6 +279,101 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr"); expect(zumSchluss.repositories[0].repoName).toBe("swr");
}); });
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN); await login(request, ADMIN);
+75
View File
@@ -72,9 +72,17 @@ test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese // dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck. // Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250); await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up(); await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible(); await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert. // Und der Server hat es auch so gespeichert.
await page.reload(); await page.reload();
@@ -119,6 +127,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
}); });
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => { test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click(); await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click(); await page.getByRole("menuitem", { name: "Abmelden" }).click();
+20 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.9.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "0.9.0", "version": "1.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
@@ -2574,6 +2575,19 @@
} }
} }
}, },
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -2866,10 +2880,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"devOptional": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
+2 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -30,9 +30,6 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:all": "npm test && npm run test:e2e" "test:all": "npm test && npm run test:e2e"
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
@@ -59,6 +56,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
+4
View File
@@ -315,6 +315,9 @@ model Project {
accent String @default("violet") accent String @default("violet")
tags String[] @default([]) tags String[] @default([])
favorite Boolean @default(false) favorite Boolean @default(false)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
position Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen. /// Einrichten des ersten Kontos uebernommen werden koennen.
@@ -343,6 +346,7 @@ model Project {
@@index([status]) @@index([status])
@@index([updatedAt]) @@index([updatedAt])
@@index([ownerId]) @@index([ownerId])
@@index([ownerId, status, position])
} }
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren – /// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+13 -1
View File
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity"; import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; import {
nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories"; import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks"; import { refreshDerivedProgress } from "@/lib/tasks";
@@ -82,6 +87,13 @@ export async function PATCH(request: Request, { params }: Params) {
if (data[key] !== undefined) update[key] = data[key]; if (data[key] !== undefined) update[key] = data[key];
} }
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
// angekommen ist.
if (data.status !== undefined && data.status !== existing.status) {
update.position = await nextProjectPosition(auth.user.id, data.status);
}
await prisma.project.update({ where: { id }, data: update }); await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin // Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+3 -1
View File
@@ -8,7 +8,7 @@ import {
repositoriesOf, repositoriesOf,
type ExportedRepository, type ExportedRepository,
} from "@/lib/export/format"; } from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects"; import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { import {
IMPORT_MAX_BODY_BYTES, IMPORT_MAX_BODY_BYTES,
@@ -164,6 +164,8 @@ export async function POST(request: Request) {
} }
const data = { const data = {
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
position: await nextProjectPosition(auth.user.id, incoming.status),
name: incoming.name, name: incoming.name,
summary: incoming.summary ?? null, summary: incoming.summary ?? null,
description: incoming.description ?? null, description: incoming.description ?? null,
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Neue Reihenfolge im Brett nach dem Ziehen.
*
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
* anzurichten.
*/
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
// untergeschobene IDs verschieben.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: requestedIds } },
select: { id: true, status: true, updatedAt: true },
});
const ownedIds = new Set(owned.map((project) => project.id));
const vorher = new Map(owned.map((project) => [project.id, project]));
const updates: Prisma.PrismaPromise<unknown>[] = [];
const entries: Parameters<typeof recordActivities>[0] = [];
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
ids.forEach((projectId, index) => {
const before = vorher.get(projectId);
if (!ownedIds.has(projectId) || !before) return;
const gewandert = before.status !== status;
updates.push(
prisma.project.update({
where: { id: projectId },
data: {
status,
position: index,
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
...(gewandert ? {} : { updatedAt: before.updatedAt }),
},
}),
);
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
if (gewandert) {
entries.push({
projectId,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, status),
meta: { from: before.status, to: status },
});
}
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+3 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity"; import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { projectListInclude, uniqueSlug } from "@/lib/projects"; import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
import { applyLeadingRepo } from "@/lib/repositories"; import { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -87,6 +87,8 @@ export async function POST(request: Request) {
accent: data.accent, accent: data.accent,
tags: data.tags, tags: data.tags,
favorite: data.favorite, favorite: data.favorite,
// Hinten an die Statusspalte des Bretts.
position: await nextProjectPosition(auth.user.id, data.status),
}, },
select: { id: true }, select: { id: true },
}); });
+16 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { ChangelogButton } from "@/components/ChangelogButton";
import { CommandPalette } from "@/components/CommandPalette"; import { CommandPalette } from "@/components/CommandPalette";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
@@ -20,6 +21,20 @@ export async function generateMetadata(): Promise<Metadata> {
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.", "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME, applicationName: APP_NAME,
robots: { index: false, follow: false }, robots: { index: false, follow: false },
manifest: "/manifest.webmanifest",
// iOS liest das Manifest nicht; dort braucht es diese Angaben einzeln.
appleWebApp: { capable: true, title: APP_NAME, statusBarStyle: "black-translucent" },
// Beide Eintraege ausdruecklich: sobald hier etwas steht, ersetzt es den
// Verweis, den Next aus app/icon.svg von selbst erzeugt. Ohne die erste
// Zeile haette die Seite gar kein Symbol mehr und der Browser fiele auf
// /favicon.ico zurueck – das es hier nicht gibt.
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
}; };
} }
@@ -56,7 +71,7 @@ export default async function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8"> <footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span> <span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v{APP_VERSION}</span> <ChangelogButton version={APP_VERSION} />
</div> </div>
</footer> </footer>
</body> </body>
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
/**
* Web-App-Manifest: macht Nebula auf dem Handy installierbar.
*
* Als Funktion und nicht als Konstante, aus demselben Grund wie bei
* generateMetadata: nur so wird APP_NAME zur Laufzeit gelesen. Ein
* konstanter Export waere beim Build eingefroren und liesse sich im
* fertigen Docker-Image nicht mehr aendern.
*/
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${APP_NAME} – ${APP_TAGLINE} Dashboard`,
short_name: APP_NAME,
description:
"Uebersicht ueber alle Vibe-Coding-Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
// Ohne Adressleiste, aber mit den Systemleisten – die Anwendung braucht
// keinen Vollbildmodus, wohl aber die Uhrzeit und den Akkustand.
display: "standalone",
start_url: "/",
scope: "/",
background_color: "#05060f",
theme_color: "#05060f",
lang: "de",
orientation: "any",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Getrennte Fassung mit mehr Rand: Android schneidet maskierbare
// Symbole je nach Geraet rund, eckig oder als Tropfen zu.
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
shortcuts: [
{ name: "Dashboard", url: "/" },
{ name: "Mein Konto", url: "/account" },
],
};
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Star, Tag, Trash2, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
/**
* Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist,
* und liegt fest am unteren Rand – bei einer langen Liste waere sie oben
* sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt.
*/
export function BulkBar({
ids,
onDone,
onClear,
}: {
ids: string[];
onDone: () => void;
onClear: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const statusRef = useRef<HTMLButtonElement>(null);
const tagsRef = useRef<HTMLButtonElement>(null);
async function apply(body: Record<string, unknown>) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
onDone();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
setStatusOpen(false);
setTagsOpen(false);
}
}
async function remove() {
if (
!window.confirm(
`${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` +
"unwiderruflich loeschen?",
)
) {
return;
}
await apply({ action: "delete" });
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4">
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt
</span>
<span className="h-5 w-px bg-white/10" aria-hidden />
<button
ref={statusRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setStatusOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={statusOpen}
>
Status
</button>
<Popover
open={statusOpen}
anchorRef={statusRef}
onClose={() => setStatusOpen(false)}
align="start"
>
<div role="menu" className="min-w-[12rem] p-1">
{STATUS_ORDER.map((status) => (
<button
key={status}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-slate-300 hover:bg-white/6"
onClick={() => apply({ action: "status", status })}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_META[status].color }}
/>
{STATUS_META[status].label}
</button>
))}
</div>
</Popover>
<button
ref={tagsRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTagsOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={tagsOpen}
>
<Tag className="h-3.5 w-3.5" />
Tags
</button>
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start">
<div className="w-64 space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
<input
id="bulk-tags"
className="field"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
placeholder="experiment, archiv"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
className="btn btn-primary flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "add" })
}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-ghost flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "remove" })
}
>
Entfernen
</button>
</div>
</div>
</Popover>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => apply({ action: "favorite", favorite: true })}
disabled={busy}
title="Als Favoriten markieren"
>
<Star className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Löschen
</button>
{busy ? <Loader2 className="h-4 w-4 animate-spin text-slate-400" /> : null}
<button
type="button"
onClick={onClear}
className="rounded-lg p-1.5 text-slate-500 hover:text-white"
aria-label="Auswahl aufheben"
>
<X className="h-4 w-4" />
</button>
{error ? (
<p className={cn("w-full px-1 text-xs text-rose-300")}>{error}</p>
) : null}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { CHANGELOG, KIND_META } from "@/lib/changelog";
import { cn, formatDate } from "@/lib/utils";
/**
* Die Versionsnummer in der Fusszeile oeffnet den Aenderungsverlauf.
*
* Kein eigener Menuepunkt: gesucht wird so etwas erfahrungsgemaess dort, wo
* die Version steht – und einen Platz in der Navigation ist es nicht wert.
*/
export function ChangelogButton({ version }: { version: string }) {
const [open, setOpen] = useState(false);
const aktuell = CHANGELOG[0]?.version;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded font-mono text-slate-600 underline decoration-dotted underline-offset-4 transition-colors hover:text-violet-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
title="Was ist neu?"
>
v{version}
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
width="lg"
title="Änderungsverlauf"
description={
// Ein Hinweis, falls die laufende Fassung neuer ist als der Eintrag.
// Das passiert, wenn jemand die Version hochzaehlt und den Eintrag
// vergisst – dann soll es auffallen statt still danebenzustehen.
aktuell === version
? "Was in den letzten Fassungen dazugekommen ist."
: `Diese Instanz läuft auf v${version}; der Verlauf endet bei v${aktuell}.`
}
>
<ol className="space-y-7">
{CHANGELOG.map((release) => (
<li key={release.version}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-mono text-base font-semibold text-white">
v{release.version}
</h3>
{release.version === version ? (
<span className="rounded-full bg-violet-500/15 px-2 py-0.5 text-[10px] uppercase tracking-wider text-violet-200 ring-1 ring-violet-400/30">
installiert
</span>
) : null}
<span className="text-xs text-slate-500">{formatDate(release.date)}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{release.headline}</p>
<ul className="mt-3 space-y-2">
{release.changes.map((change, index) => {
const meta = KIND_META[change.kind];
return (
<li key={index} className="flex gap-2.5 text-sm leading-snug text-slate-300">
<span
className={cn(
"mt-0.5 h-fit shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1",
meta.className,
)}
>
{meta.label}
</span>
<span className="min-w-0">{change.text}</span>
</li>
);
})}
</ul>
</li>
))}
</ol>
</Modal>
</>
);
}
+177 -65
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -8,53 +8,81 @@ import {
DragOverlay, DragOverlay,
PointerSensor, PointerSensor,
TouchSensor, TouchSensor,
closestCorners, pointerWithin,
useDraggable,
useDroppable, useDroppable,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
type DragOverEvent,
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GitBranch, GripVertical, Loader2, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status"; import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils"; import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types"; import type { ProjectListItem } from "@/types";
type Columns = Record<ProjectStatus, ProjectListItem[]>;
/** /**
* Statusspalten mit Ziehen und Ablegen. * Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
* *
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert; * Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge * gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board * nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden. * Gruppen, hier zaehlt die Anordnung von Hand.
*
* Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert
* das Speichern, springt alles auf den Serverstand zurueck.
*/ */
export function KanbanBoard({ export function KanbanBoard({
projects, projects,
showArchived, showArchived,
selection,
onToggleSelect,
}: { }: {
projects: ProjectListItem[]; projects: ProjectListItem[];
showArchived: boolean; showArchived: boolean;
selection: Set<string>;
onToggleSelect: (id: string, selected: boolean) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine const sichtbar = useMemo(
// oertliche Ausnahme mehr. () => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
useEffect(() => { [showArchived],
setOverrides((current) => { );
const next: Record<string, ProjectStatus> = {};
for (const [id, status] of Object.entries(current)) { const group = useMemo(
const project = projects.find((entry) => entry.id === id); () => (list: ProjectListItem[]): Columns => {
if (project && project.status !== status) next[id] = status; const leer = Object.fromEntries(
STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]),
) as Columns;
for (const project of list) leer[project.status].push(project);
for (const status of STATUS_ORDER) {
// Bei gleicher Position bleibt es bei der Reihenfolge, in der die
// Liste kam – das ist die im Kopf gewaehlte Sortierung.
leer[status].sort((a, b) => a.position - b.position);
} }
return Object.keys(next).length === Object.keys(current).length ? current : next; return leer;
}); },
}, [projects]); [],
);
const [columns, setColumns] = useState<Columns>(() => group(projects));
// Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann
// noch der alte und wuerde die Karte zurueckspringen lassen.
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setColumns(group(projects));
}, [projects, group]);
const sensors = useSensors( const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte // Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
@@ -63,72 +91,138 @@ export function KanbanBoard({
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
); );
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status; const active = activeId
? Object.values(columns).flat().find((project) => project.id === activeId) ?? null
: null;
const columns = useMemo(() => { /** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
const visible = STATUS_ORDER.filter( function containerOf(key: string): ProjectStatus | null {
(status) => showArchived || status !== "ARCHIVED", if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus;
return (
STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null
); );
return visible.map((status) => ({ }
status,
items: projects.filter((project) => statusOf(project) === status),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, overrides, showArchived]);
const active = activeId ? projects.find((project) => project.id === activeId) : null;
function onDragStart(event: DragStartEvent) { function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id)); setActiveId(String(event.active.id));
} }
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((project) => project.id === activeKey);
if (!moved) return current;
const zielIndex = current[to].findIndex((project) => project.id === overKey);
const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length;
return {
...current,
[from]: current[from].filter((project) => project.id !== activeKey),
[to]: [
...current[to].slice(0, einfuegenBei),
{ ...moved, status: to },
...current[to].slice(einfuegenBei),
],
};
});
}
async function onDragEnd(event: DragEndEvent) { async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const ueber = containerOf(overKey);
if (container && container === ueber) {
const list = columns[container];
const alt = list.findIndex((project) => project.id === activeKey);
const neu = list.findIndex((project) => project.id === overKey);
if (alt >= 0 && neu >= 0 && alt !== neu) {
const sortiert = [...list];
const [moved] = sortiert.splice(alt, 1);
sortiert.splice(neu, 0, moved);
next = { ...columns, [container]: sortiert };
setColumns(next);
}
}
}
setActiveId(null); setActiveId(null);
dragging.current = false;
await persist(next);
}
const id = String(event.active.id); async function persist(next: Columns) {
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null; setSaving(true);
if (!target) return;
const project = projects.find((entry) => entry.id === id);
if (!project) return;
const previous = statusOf(project);
if (previous === target) return;
setOverrides((current) => ({ ...current, [id]: target }));
try { try {
const response = await fetch(`/api/projects/${id}`, { const response = await fetch("/api/projects/reorder", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }), body: JSON.stringify({
columns: Object.fromEntries(
sichtbar.map((status) => [status, next[status].map((project) => project.id)]),
),
}),
}); });
if (!response.ok) throw new Error("failed"); if (!response.ok) throw new Error("failed");
router.refresh(); router.refresh();
} catch { } catch {
setOverrides((current) => { // Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
const next = { ...current }; setColumns(group(projects));
delete next[id]; } finally {
return next; setSaving(false);
});
} }
} }
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]} modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)} onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(projects));
}}
> >
{saving ? (
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Reihenfolge wird gespeichert…
</p>
) : null}
<div className="flex gap-3 overflow-x-auto pb-3"> <div className="flex gap-3 overflow-x-auto pb-3">
{columns.map((column) => ( {sichtbar.map((status) => (
<Column key={column.status} status={column.status} count={column.items.length}> <Column key={status} status={status} count={columns[status].length}>
{column.items.map((project) => ( <SortableContext
<KanbanCard key={project.id} project={project} /> items={columns[status].map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{columns[status].map((project) => (
<KanbanCard
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))} ))}
</SortableContext>
</Column> </Column>
))} ))}
</div> </div>
@@ -196,28 +290,36 @@ function Column({
function KanbanCard({ function KanbanCard({
project, project,
overlay = false, overlay = false,
selected = false,
onToggleSelect,
}: { }: {
project: ProjectListItem; project: ProjectListItem;
overlay?: boolean; overlay?: boolean;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) { }) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
return ( return (
<article <article
ref={overlay ? undefined : setNodeRef} ref={overlay ? undefined : setNodeRef}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
} as React.CSSProperties
}
className={cn( className={cn(
"popover group relative overflow-hidden p-3", "popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen. // Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30", isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40", overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
selected && "ring-1 ring-violet-400/60",
)} )}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
> >
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" /> <span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
@@ -233,6 +335,16 @@ function KanbanCard({
<GripVertical className="h-3.5 w-3.5" /> <GripVertical className="h-3.5 w-3.5" />
</button> </button>
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
aria-label={`${project.name} auswaehlen`}
/>
) : null}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <Link
href={`/projects/${project.id}`} href={`/projects/${project.id}`}
+105 -3
View File
@@ -16,6 +16,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog"; import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard"; import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
@@ -54,6 +55,11 @@ function highlight(snippet: string): React.ReactNode[] {
type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban"; type ViewMode = "grid" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
const SORT_LABEL: Record<SortKey, string> = { const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert", updated: "Zuletzt geaendert",
created: "Neueste zuerst", created: "Neueste zuerst",
@@ -74,11 +80,52 @@ export function ProjectBoard({
const [sort, setSort] = useState<SortKey>("updated"); const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid"); const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
/**
* Ansicht und Sortierung ueberdauern das Neuladen.
*
* Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch
* sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern
* gelesen: der Server kennt den gespeicherten Wert nicht, und ein
* Unterschied zwischen beiden waere ein Hydrationsfehler.
*
* Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen
* wiederherzustellen waere eher verwirrend als hilfreich.
*/
useEffect(() => {
try {
const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL);
if (!gespeichert) return;
const wert = JSON.parse(gespeichert) as Partial<{
view: ViewMode;
sort: SortKey;
showArchived: boolean;
}>;
if (wert.view && VIEWS.includes(wert.view)) setView(wert.view);
if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort);
if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived);
} catch {
// Kaputter oder gesperrter Speicher: dann eben die Voreinstellung.
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
ANSICHT_SCHLUESSEL,
JSON.stringify({ view, sort, showArchived }),
);
} catch {
// Im privaten Modus mancher Browser nicht beschreibbar – kein Grund,
// die Seite scheitern zu lassen.
}
}, [view, sort, showArchived]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null); const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]); const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/** /**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite * Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
@@ -174,6 +221,28 @@ export function ProjectBoard({
})).filter((group) => group.items.length > 0); })).filter((group) => group.items.length > 0);
}, [visible]); }, [visible]);
/**
* Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine
* Massenaenderung Projekte, die man gar nicht mehr vor Augen hat.
*/
useEffect(() => {
setSelection((current) => {
if (current.size === 0) return current;
const sichtbar = new Set(visible.map((project) => project.id));
const next = new Set([...current].filter((id) => sichtbar.has(id)));
return next.size === current.size ? current : next;
});
}, [visible]);
function toggleSelect(id: string, selected: boolean) {
setSelection((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleStatus(status: ProjectStatus) { function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) => setStatusFilter((current) =>
current.includes(status) current.includes(status)
@@ -218,6 +287,14 @@ export function ProjectBoard({
className="field w-auto min-w-[11rem]" className="field w-auto min-w-[11rem]"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
// stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend.
disabled={view === "kanban"}
title={
view === "kanban"
? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst."
: undefined
}
> >
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ( {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -388,11 +465,22 @@ export function ProjectBoard({
{visible.length === 0 ? ( {visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} /> <EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? ( ) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} /> <KanbanBoard
projects={visible}
showArchived={showArchived}
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => ( {visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -416,7 +504,13 @@ export function ProjectBoard({
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => ( {group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
</div> </div>
@@ -425,6 +519,14 @@ export function ProjectBoard({
</div> </div>
)} )}
{selection.size > 0 ? (
<BulkBar
ids={[...selection]}
onDone={() => setSelection(new Set())}
onClear={() => setSelection(new Set())}
/>
) : null}
<Popover <Popover
anchorRef={dataButtonRef} anchorRef={dataButtonRef}
open={dataMenuOpen} open={dataMenuOpen}
+30 -2
View File
@@ -29,7 +29,17 @@ const PROVIDER_LABEL: Record<string, string> = {
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"), * Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck. * interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/ */
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { export function ProjectCard({
project,
index = 0,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
index?: number;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
// Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es // Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
@@ -60,7 +70,10 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
return ( return (
<article <article
className="glass card-glow fade-up group relative overflow-hidden p-5" className={cn(
"glass card-glow fade-up group relative overflow-hidden p-5",
selected && "ring-1 ring-violet-400/60",
)}
style={ style={
{ {
"--accent-from": accent.from, "--accent-from": accent.from,
@@ -81,6 +94,21 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Gedaempft, aber immer da. Nur beim Ueberfahren einzublenden
waere auf einem Touchgeraet gar nicht erreichbar – und wer
es nie sieht, weiss nicht, dass es die Auswahl gibt. */}
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-3.5 w-3.5 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200"> <h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name} {project.name}
</h3> </h3>
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { CHANGELOG, KIND_META } from "./changelog";
import { APP_VERSION } from "./config";
/**
* Der Verlauf und die Versionsnummer sind zwei Angaben, die zusammenpassen
* muessen. Genau daran ist dieses Projekt schon einmal gescheitert: die
* Version stand in der Fusszeile und in der package.json und zeigte
* verschiedene Werte.
*/
describe("Aenderungsverlauf", () => {
it("beginnt mit der Fassung, die auch installiert ist", () => {
expect(CHANGELOG[0].version).toBe(APP_VERSION);
});
it("nennt jede Fassung nur einmal", () => {
const versionen = CHANGELOG.map((release) => release.version);
expect(new Set(versionen).size).toBe(versionen.length);
});
it("zaehlt absteigend", () => {
const zahl = (version: string) =>
version.split(".").reduce((sum, teil) => sum * 1000 + Number(teil), 0);
for (let index = 1; index < CHANGELOG.length; index += 1) {
expect(
zahl(CHANGELOG[index - 1].version),
`${CHANGELOG[index - 1].version} muss ueber ${CHANGELOG[index].version} stehen`,
).toBeGreaterThan(zahl(CHANGELOG[index].version));
}
});
it("hat zu jeder Fassung ein Datum, eine Zeile und mindestens eine Aenderung", () => {
for (const release of CHANGELOG) {
expect(release.version, "Version im Format x.y.z").toMatch(/^\d+\.\d+\.\d+$/);
expect(Number.isNaN(Date.parse(release.date)), `Datum von ${release.version}`).toBe(false);
expect(release.headline.length, `Zeile zu ${release.version}`).toBeGreaterThan(0);
expect(release.changes.length, `Eintraege zu ${release.version}`).toBeGreaterThan(0);
}
});
it("kennt zu jeder Art eine Beschriftung", () => {
for (const release of CHANGELOG) {
for (const change of release.changes) {
expect(KIND_META[change.kind], `${change.kind} in v${release.version}`).toBeDefined();
expect(change.text.trim().length).toBeGreaterThan(0);
}
}
});
});
+307
View File
@@ -0,0 +1,307 @@
/**
* Was sich von Fassung zu Fassung geaendert hat.
*
* Bewusst hier und nicht als CHANGELOG.md: die Liste wird in der Anwendung
* angezeigt, und zwei Quellen liefen prompt auseinander – wie es der
* Versionsnummer schon einmal passiert ist, die eine Zeit lang in der
* Fusszeile und in der package.json stand und verschiedene Werte zeigte.
*
* Die oberste Fassung ist die aktuelle. Neue Eintraege kommen nach vorne.
*/
export type ChangeKind = "neu" | "besser" | "behoben";
export interface ChangeEntry {
kind: ChangeKind;
text: string;
}
export interface Release {
version: string;
/** ISO-Datum, ohne Uhrzeit. */
date: string;
/** Ein Satz darueber, worum es in dieser Fassung ging. */
headline: string;
changes: ChangeEntry[];
}
export const KIND_META: Record<ChangeKind, { label: string; className: string }> = {
neu: {
label: "Neu",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
besser: {
label: "Besser",
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
},
behoben: {
label: "Behoben",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
export const CHANGELOG: Release[] = [
{
version: "1.1.0",
date: "2026-07-26",
headline: "Das Brett wird zur Arbeitsfläche.",
changes: [
{
kind: "neu",
text:
"Freie Reihenfolge im Brett: Karten lassen sich innerhalb einer Spalte anordnen, " +
"die Position bleibt erhalten.",
},
{
kind: "neu",
text:
"Mehrfachauswahl. Über die Kästchen mehrere Projekte wählen und Status, Tags oder " +
"Favoriten in einem Zug ändern — oder alle auf einmal löschen.",
},
{
kind: "neu",
text:
"Als App installierbar: Nebula bringt ein Web-Manifest und eigene Symbole mit und " +
"lässt sich auf dem Handy zum Startbildschirm hinzufügen.",
},
{
kind: "neu",
text: "Dieser Änderungsverlauf, erreichbar über die Versionsnummer in der Fußzeile.",
},
{
kind: "besser",
text:
"Ansicht, Sortierung und der Archivschalter überdauern das Neuladen. Wer sein " +
"Brett von Hand anordnet, will es beim nächsten Aufruf auch sehen.",
},
{
kind: "behoben",
text:
"Neu angelegte, importierte und verschobene Projekte bekamen keine Position und " +
"stapelten sich im Brett alle auf demselben Platz.",
},
{
kind: "besser",
text:
"Umsortieren im Brett fasst das Änderungsdatum nicht mehr an. Vorher schob ein " +
"Aufräumen sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.",
},
{
kind: "besser",
text:
"Die Sortierung im Kopf ist im Brett abgeschaltet — dort zählt die Anordnung von Hand. " +
"Sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.",
},
{
kind: "besser",
text:
"Prisma liest seine Einstellungen aus prisma.config.ts statt aus der package.json. " +
"Damit ist die Warnung vor Prisma 7 weg.",
},
],
},
{
version: "1.0.0",
date: "2026-07-26",
headline: "Der Funktionsumfang ist beisammen.",
changes: [
{
kind: "neu",
text:
"Aktivitätsverlauf je Projekt: Statuswechsel, Notizen, Aufgaben und neue Commits " +
"in einer Zeitleiste.",
},
{
kind: "neu",
text:
"Zwei-Faktor-Anmeldung per TOTP, mit QR-Code zum Einrichten und zehn " +
"Wiederherstellungscodes für den Fall, dass das Gerät verloren geht.",
},
{
kind: "neu",
text:
"Mehrere Repositories je Projekt — Frontend und Backend gehören in eine Karte. " +
"Jedes mit eigenen Kennzahlen, eigener CI und eigenem Abgleich.",
},
{
kind: "besser",
text:
"Benachrichtigungen merken sich den gemeldeten Zustand je Repository. Vorher " +
"verdeckte ein rotes Frontend die rote Pipeline des Backends.",
},
{
kind: "besser",
text:
"Stillstand bemisst sich am jüngsten Commit über alle Repositories: an einem " +
"Vorhaben wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.",
},
{
kind: "behoben",
text:
"Der Verlauf lieferte ohne ausdrückliche Angabe genau einen Eintrag statt dreißig " +
"— Number(null) ist 0, nicht „nicht angegeben“.",
},
{
kind: "behoben",
text:
"Die Issue-Übernahme antwortete mit „nicht gefunden“, obwohl gar kein bestimmtes " +
"Repository angefragt war.",
},
],
},
{
version: "0.9.0",
date: "2026-07-26",
headline: "Schneller ans Ziel.",
changes: [
{
kind: "neu",
text:
"Schnellsuche mit Strg+K: Projekte, Notizen und Bereiche, bedienbar allein mit " +
"der Tastatur.",
},
{
kind: "neu",
text:
"Offene Git-Issues als Aufgaben übernehmen, mit Titel, Labels und Link zurück. " +
"Bereits übernommene werden übersprungen.",
},
],
},
{
version: "0.8.0",
date: "2026-07-26",
headline: "Der sanfte Stups.",
changes: [
{
kind: "neu",
text:
"Stillstehende Projekte melden sich, wenn länger kein Commit kam. Die Schwelle " +
"steht im Adminbereich.",
},
{
kind: "behoben",
text:
"Der neue Anlass fehlte im Prüfschema — die Oberfläche bot ihn an, der Server " +
"lehnte ihn ab. Ein Test hält beide Listen seitdem zusammen.",
},
],
},
{
version: "0.7.0",
date: "2026-07-26",
headline: "Bescheid bekommen, ohne nachzusehen.",
changes: [
{
kind: "neu",
text:
"Benachrichtigungen über ntfy, Webhook (HMAC-signiert) und E-Mail — bei " +
"fehlgeschlagenem Abgleich, roter CI oder neuer Version.",
},
{
kind: "besser",
text:
"Gemeldet wird nur bei einer Änderung. Sonst käme bei einem Repo mit abgelaufenem " +
"Token mit jedem Durchlauf dieselbe Nachricht.",
},
{
kind: "behoben",
text: "Im ntfy-Betreff wurden Emojis zu Fragezeichen, weil die Kopfzeile nur Latin-1 kann.",
},
],
},
{
version: "0.6.0",
date: "2026-07-26",
headline: "Prüfen statt behaupten.",
changes: [
{
kind: "neu",
text: "Ende-zu-Ende-Tests mit Playwright gegen die gebaute Anwendung und eine eigene Datenbank.",
},
{ kind: "neu", text: "CI-Pipeline für Gitea Actions." },
],
},
{
version: "0.5.0",
date: "2026-07-26",
headline: "Aufgaben je Projekt.",
changes: [
{
kind: "neu",
text: "Aufgabenbrett mit sortierbaren Spalten, Fälligkeitsdatum und Labels.",
},
{ kind: "neu", text: "Fortschritt wahlweise aus dem Verhältnis erledigter Aufgaben." },
],
},
{
version: "0.4.0",
date: "2026-07-26",
headline: "Ziehen und ablegen.",
changes: [
{ kind: "neu", text: "Kanban-Ansicht: Status per Ziehen ändern." },
{ kind: "neu", text: "CI-Status je Branch und Commit-Aktivität als Verlaufslinie." },
{
kind: "behoben",
text:
"Die Verlaufslinie log: bei 100 Commits innerhalb weniger Tage zeigte sie für die " +
"übrigen Tage Nullen, obwohl schlicht keine Daten vorlagen.",
},
],
},
{
version: "0.3.0",
date: "2026-07-26",
headline: "Text und Suche.",
changes: [
{ kind: "neu", text: "Markdown in Beschreibung und Notizen." },
{ kind: "neu", text: "Volltextsuche über Notizen, mit hervorgehobenen Fundstellen." },
{ kind: "neu", text: "Listen offener Pull Requests und Issues." },
{ kind: "behoben", text: "Die Versionsnummer stand an zwei Stellen und lief auseinander." },
],
},
{
version: "0.2.0",
date: "2026-07-25",
headline: "Konten und Härtung.",
changes: [
{ kind: "neu", text: "Benutzerverwaltung mit Passwort und Passkeys, Einzel- und Mehrbenutzerbetrieb." },
{ kind: "neu", text: "Export, Import und vollständige Sicherung." },
{ kind: "neu", text: "Automatischer Abgleich nach Zeitplan, einstellbar im Adminbereich." },
{
kind: "besser",
text:
"Härtung: CSRF-Prüfung, Content-Security-Policy mit Nonce, SSRF-Schutz mit " +
"Namensauflösung, Ratenbegrenzung und harte Datentrennung je Konto.",
},
{
kind: "behoben",
text:
"Menüs verschwanden hinter den Karten. Ursache war der Weichzeichner der " +
"Glasoptik, der einen eigenen Bezugsrahmen aufspannt; Menüs hängen seitdem am " +
"Seitenende statt in der Karte.",
},
{
kind: "behoben",
text:
"Eingabefelder überlappten ihre Symbole, weil eigene CSS-Klassen außerhalb der " +
"Kaskadenschichten gegen die Hilfsklassen gewannen.",
},
],
},
{
version: "0.1.0",
date: "2026-07-25",
headline: "Der Anfang.",
changes: [
{
kind: "neu",
text:
"Projekte mit Status, Priorität, Fortschritt, Tags und Notizen — von der reinen " +
"Idee bis zum fertigen Repo.",
},
{ kind: "neu", text: "Git-Anbindung an GitHub, GitLab und Gitea über Access Token." },
{ kind: "neu", text: "Betrieb per Docker oder eigenständig, Daten in einer lokalen PostgreSQL." },
],
},
];
+20 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import { Prisma, type ProjectStatus } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { slugify } from "@/lib/utils"; import { slugify } from "@/lib/utils";
@@ -28,6 +28,25 @@ export async function uniqueSlug(
return `${base}-${Date.now()}`; return `${base}-${Date.now()}`;
} }
/**
* Naechste freie Position am Ende einer Statusspalte im Brett.
*
* Muss bei jedem Anlegen und bei jedem Statuswechsel gesetzt werden – sonst
* stehen alle auf 0 und das Brett zeigt eine Reihenfolge, die sich bei jedem
* Laden anders ergeben kann.
*/
export async function nextProjectPosition(
ownerId: string,
status: ProjectStatus,
): Promise<number> {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/** /**
* Verbindungsfelder, die an den Browser gehen duerfen. * Verbindungsfelder, die an den Browser gehen duerfen.
* tokenCipher ist bewusst nicht dabei. * tokenCipher ist bewusst nicht dabei.
+62 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { NOTIFICATION_EVENTS } from "@/lib/notify/types"; import { NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { notificationTargetCreateSchema } from "./validation"; import {
notificationTargetCreateSchema,
projectBulkSchema,
projectReorderSchema,
} from "./validation";
/** /**
* Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen * Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen
@@ -70,3 +74,60 @@ describe("notificationTargetCreateSchema", () => {
} }
}); });
}); });
describe("projectReorderSchema", () => {
it("nimmt Spalten mit Listen an", () => {
const parsed = projectReorderSchema.safeParse({
columns: { IDEA: ["a", "b"], DONE: [] },
});
expect(parsed.success).toBe(true);
});
it("weist unbekannte Spalten ab", () => {
expect(
projectReorderSchema.safeParse({ columns: { PHANTASIE: ["a"] } }).success,
).toBe(false);
});
it("weist einen leeren Aufruf ab, statt ihn wirkungslos durchzuwinken", () => {
expect(projectReorderSchema.safeParse({ columns: {} }).success).toBe(false);
});
});
describe("projectBulkSchema", () => {
const ids = ["a", "b"];
it("verlangt zu jeder Aktion die noetige Angabe", () => {
// Ohne diese Pruefungen kaeme die Aktion durch und taete nichts – der
// schlimmste Fall, weil niemand merkt, dass nichts passiert ist.
expect(projectBulkSchema.safeParse({ ids, action: "status" }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "tags" }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "tags", tags: [] }).success).toBe(false);
expect(projectBulkSchema.safeParse({ ids, action: "favorite" }).success).toBe(false);
});
it("laesst vollstaendige Aktionen durch", () => {
expect(
projectBulkSchema.safeParse({ ids, action: "status", status: "DONE" }).success,
).toBe(true);
expect(
projectBulkSchema.safeParse({ ids, action: "tags", tags: ["archiv"] }).success,
).toBe(true);
expect(
projectBulkSchema.safeParse({ ids, action: "favorite", favorite: false }).success,
).toBe(true);
// Loeschen braucht nichts ausser den IDs.
expect(projectBulkSchema.safeParse({ ids, action: "delete" }).success).toBe(true);
});
it("ergaenzt standardmaessig, statt zu entfernen", () => {
const parsed = projectBulkSchema.parse({ ids, action: "tags", tags: ["x"] });
expect(parsed.tagMode).toBe("add");
});
it("braucht mindestens eine ID und deckelt die Menge", () => {
expect(projectBulkSchema.safeParse({ ids: [], action: "delete" }).success).toBe(false);
const zuViele = Array.from({ length: 201 }, (_, index) => `id-${index}`);
expect(projectBulkSchema.safeParse({ ids: zuViele, action: "delete" }).success).toBe(false);
});
});
+35
View File
@@ -35,6 +35,41 @@ export const projectCreateSchema = z.object({
export const projectUpdateSchema = projectCreateSchema.partial(); export const projectUpdateSchema = projectCreateSchema.partial();
/**
* Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die
* vollstaendige Liste in ihrer neuen Reihenfolge – daraus ergeben sich Status
* und Position eindeutig, und der Aufruf laesst sich gefahrlos wiederholen.
*/
export const projectReorderSchema = z.object({
columns: z
.record(z.enum(STATUS), z.array(z.string().min(1)).max(500))
.refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"),
});
/** Dieselbe Aenderung fuer mehrere Projekte auf einmal. */
export const projectBulkSchema = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
action: z.enum(["status", "tags", "favorite", "delete"]),
status: z.enum(STATUS).optional(),
/** Bei action "tags": ergaenzen oder entfernen. */
tags: z.array(z.string().trim().min(1).max(30)).max(12).optional(),
tagMode: z.enum(["add", "remove"]).default("add"),
favorite: z.boolean().optional(),
})
.refine((value) => value.action !== "status" || value.status !== undefined, {
message: "Ohne Zielstatus laesst sich nichts verschieben",
path: ["status"],
})
.refine((value) => value.action !== "tags" || (value.tags?.length ?? 0) > 0, {
message: "Ohne Tags gibt es nichts zu aendern",
path: ["tags"],
})
.refine((value) => value.action !== "favorite" || value.favorite !== undefined, {
message: "Kein Zielzustand angegeben",
path: ["favorite"],
});
// ── Repositories ───────────────────────────────────────────── // ── Repositories ─────────────────────────────────────────────
export const repositoryCreateSchema = z.object({ export const repositoryCreateSchema = z.object({
+11 -2
View File
@@ -20,12 +20,21 @@ const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */ /** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"]; const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"]; const PUBLIC_API_PREFIXES = ["/api/auth/"];
/** Der Docker-Healthcheck hat kein Cookie. */ /**
const PUBLIC_API_EXACT = ["/api/health"]; * Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
* umgeleitet, liesse sich die Anwendung nicht installieren und die
* Anmeldeseite haette kein Symbol.
*/
const PUBLIC_API_EXACT = ["/api/health", "/manifest.webmanifest"];
/** Bilder und aehnliches im Wurzelverzeichnis verraten nichts. */
const PUBLIC_FILE = /\.(png|svg|ico|webmanifest|txt|webp)$/;
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) return true; if (PUBLIC_PATHS.includes(pathname)) return true;
if (PUBLIC_API_EXACT.includes(pathname)) return true; if (PUBLIC_API_EXACT.includes(pathname)) return true;
if (PUBLIC_FILE.test(pathname)) return true;
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix)); return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
} }