Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce0a90cc3 | ||
|
|
16e8d31277 | ||
|
|
d97b6f650a | ||
|
|
49e253f70b | ||
|
|
1400bca047 |
+26
-2
@@ -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
|
||||
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
|
||||
|
||||
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.
|
||||
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
|
||||
Auf Touchgeräten nach kurzem Halten.
|
||||
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren
|
||||
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut.
|
||||
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
|
||||
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
|
||||
|
||||
@@ -461,6 +477,14 @@ letzten Zustellung stehen an der Karte.
|
||||
Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
|
||||
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
|
||||
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
@@ -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
|
||||
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
|
||||
@@ -42,7 +37,7 @@ Frei sortieren hieße, eine Position je Projekt zu speichern.
|
||||
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
||||
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
|
||||
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||
@@ -54,14 +49,11 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||
| Idee | Aufwand | Nutzen |
|
||||
| --- | --- | --- |
|
||||
| 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" |
|
||||
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
|
||||
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
||||
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
||||
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -101,6 +93,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
|
||||
| **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.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
|
||||
|
||||
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
|
||||
|
||||
@@ -279,6 +279,101 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
|
||||
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 }) => {
|
||||
await login(request, ADMIN);
|
||||
|
||||
|
||||
@@ -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
|
||||
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
|
||||
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 expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
|
||||
expect((await gespeichert).ok()).toBeTruthy();
|
||||
|
||||
// Und der Server hat es auch so gespeichert.
|
||||
await page.reload();
|
||||
@@ -119,6 +127,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
||||
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 }) => {
|
||||
await page.getByRole("button", { name: /Benutzermenue/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Abmelden" }).click();
|
||||
|
||||
Generated
+20
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "0.9.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nebula",
|
||||
"version": "0.9.0",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"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": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
@@ -2866,10 +2880,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"devOptional": true,
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
+2
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||
"scripts": {
|
||||
@@ -30,9 +30,6 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:all": "npm test && npm run test:e2e"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
@@ -59,6 +56,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@@ -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");
|
||||
@@ -315,6 +315,9 @@ model Project {
|
||||
accent String @default("violet")
|
||||
tags String[] @default([])
|
||||
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
|
||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||
@@ -343,6 +346,7 @@ model Project {
|
||||
@@index([status])
|
||||
@@index([updatedAt])
|
||||
@@index([ownerId])
|
||||
@@index([ownerId, status, position])
|
||||
}
|
||||
|
||||
/// 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 |
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server";
|
||||
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
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 { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
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];
|
||||
}
|
||||
|
||||
// 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 });
|
||||
|
||||
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
repositoriesOf,
|
||||
type ExportedRepository,
|
||||
} from "@/lib/export/format";
|
||||
import { uniqueSlug } from "@/lib/projects";
|
||||
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import {
|
||||
IMPORT_MAX_BODY_BYTES,
|
||||
@@ -164,6 +164,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const data = {
|
||||
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
|
||||
position: await nextProjectPosition(auth.user.id, incoming.status),
|
||||
name: incoming.name,
|
||||
summary: incoming.summary ?? null,
|
||||
description: incoming.description ?? null,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { recordActivity } from "@/lib/activity";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||
import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||
import { applyLeadingRepo } from "@/lib/repositories";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
@@ -87,6 +87,8 @@ export async function POST(request: Request) {
|
||||
accent: data.accent,
|
||||
tags: data.tags,
|
||||
favorite: data.favorite,
|
||||
// Hinten an die Statusspalte des Bretts.
|
||||
position: await nextProjectPosition(auth.user.id, data.status),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { ChangelogButton } from "@/components/ChangelogButton";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
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.",
|
||||
applicationName: APP_NAME,
|
||||
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">
|
||||
<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 className="font-mono text-slate-600">v{APP_VERSION}</span>
|
||||
<ChangelogButton version={APP_VERSION} />
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+178
-66
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -8,53 +8,81 @@ import {
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
closestCorners,
|
||||
useDraggable,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
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 { CiBadge } from "@/components/CiBadge";
|
||||
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
|
||||
import { cn, truncate } from "@/lib/utils";
|
||||
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;
|
||||
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge
|
||||
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board
|
||||
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden.
|
||||
* Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
|
||||
* gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
|
||||
* nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
|
||||
* 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({
|
||||
projects,
|
||||
showArchived,
|
||||
selection,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
projects: ProjectListItem[];
|
||||
showArchived: boolean;
|
||||
selection: Set<string>;
|
||||
onToggleSelect: (id: string, selected: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine
|
||||
// oertliche Ausnahme mehr.
|
||||
useEffect(() => {
|
||||
setOverrides((current) => {
|
||||
const next: Record<string, ProjectStatus> = {};
|
||||
for (const [id, status] of Object.entries(current)) {
|
||||
const project = projects.find((entry) => entry.id === id);
|
||||
if (project && project.status !== status) next[id] = status;
|
||||
const sichtbar = useMemo(
|
||||
() => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
|
||||
[showArchived],
|
||||
);
|
||||
|
||||
const group = useMemo(
|
||||
() => (list: ProjectListItem[]): Columns => {
|
||||
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;
|
||||
});
|
||||
}, [projects]);
|
||||
return leer;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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(
|
||||
// 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 } }),
|
||||
);
|
||||
|
||||
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(() => {
|
||||
const visible = STATUS_ORDER.filter(
|
||||
(status) => showArchived || status !== "ARCHIVED",
|
||||
/** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
|
||||
function containerOf(key: string): ProjectStatus | null {
|
||||
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) {
|
||||
dragging.current = true;
|
||||
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) {
|
||||
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);
|
||||
dragging.current = false;
|
||||
await persist(next);
|
||||
}
|
||||
|
||||
const id = String(event.active.id);
|
||||
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null;
|
||||
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 }));
|
||||
|
||||
async function persist(next: Columns) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
const response = await fetch("/api/projects/reorder", {
|
||||
method: "PATCH",
|
||||
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");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setOverrides((current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
|
||||
setColumns(group(projects));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
collisionDetection={pointerWithin}
|
||||
modifiers={[restrictToWindowEdges]}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
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">
|
||||
{columns.map((column) => (
|
||||
<Column key={column.status} status={column.status} count={column.items.length}>
|
||||
{column.items.map((project) => (
|
||||
<KanbanCard key={project.id} project={project} />
|
||||
))}
|
||||
{sichtbar.map((status) => (
|
||||
<Column key={status} status={status} count={columns[status].length}>
|
||||
<SortableContext
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
@@ -196,28 +290,36 @@ function Column({
|
||||
function KanbanCard({
|
||||
project,
|
||||
overlay = false,
|
||||
selected = false,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
project: ProjectListItem;
|
||||
overlay?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (id: string, selected: boolean) => void;
|
||||
}) {
|
||||
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 (
|
||||
<article
|
||||
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(
|
||||
"popover group relative overflow-hidden p-3",
|
||||
// Das Original bleibt als blasser Platzhalter stehen.
|
||||
isDragging && !overlay && "opacity-30",
|
||||
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" />
|
||||
|
||||
@@ -233,6 +335,16 @@ function KanbanCard({
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</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">
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { BulkBar } from "@/components/BulkBar";
|
||||
import { ImportDialog } from "@/components/ImportDialog";
|
||||
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
@@ -54,6 +55,11 @@ function highlight(snippet: string): React.ReactNode[] {
|
||||
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
|
||||
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> = {
|
||||
updated: "Zuletzt geaendert",
|
||||
created: "Neueste zuerst",
|
||||
@@ -74,11 +80,52 @@ export function ProjectBoard({
|
||||
const [sort, setSort] = useState<SortKey>("updated");
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
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 [importOpen, setImportOpen] = useState(false);
|
||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
|
||||
@@ -174,6 +221,28 @@ export function ProjectBoard({
|
||||
})).filter((group) => group.items.length > 0);
|
||||
}, [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) {
|
||||
setStatusFilter((current) =>
|
||||
current.includes(status)
|
||||
@@ -218,6 +287,14 @@ export function ProjectBoard({
|
||||
className="field w-auto min-w-[11rem]"
|
||||
value={sort}
|
||||
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) => (
|
||||
<option key={key} value={key}>
|
||||
@@ -388,11 +465,22 @@ export function ProjectBoard({
|
||||
{visible.length === 0 ? (
|
||||
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
|
||||
) : view === "kanban" ? (
|
||||
<KanbanBoard projects={visible} showArchived={showArchived} />
|
||||
<KanbanBoard
|
||||
projects={visible}
|
||||
showArchived={showArchived}
|
||||
selection={selection}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
) : view === "grid" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{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>
|
||||
) : (
|
||||
@@ -416,7 +504,13 @@ export function ProjectBoard({
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{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>
|
||||
@@ -425,6 +519,14 @@ export function ProjectBoard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection.size > 0 ? (
|
||||
<BulkBar
|
||||
ids={[...selection]}
|
||||
onDone={() => setSelection(new Set())}
|
||||
onClear={() => setSelection(new Set())}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Popover
|
||||
anchorRef={dataButtonRef}
|
||||
open={dataMenuOpen}
|
||||
|
||||
@@ -29,7 +29,17 @@ const PROVIDER_LABEL: Record<string, string> = {
|
||||
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
|
||||
* 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 priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
// 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 (
|
||||
<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={
|
||||
{
|
||||
"--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="min-w-0 flex-1">
|
||||
<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">
|
||||
{project.name}
|
||||
</h3>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Prisma, type ProjectStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { slugify } from "@/lib/utils";
|
||||
|
||||
@@ -28,6 +28,25 @@ export async function uniqueSlug(
|
||||
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.
|
||||
* tokenCipher ist bewusst nicht dabei.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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
|
||||
@@ -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,6 +35,41 @@ export const projectCreateSchema = z.object({
|
||||
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
export const repositoryCreateSchema = z.object({
|
||||
|
||||
+11
-2
@@ -20,12 +20,21 @@ const SESSION_COOKIE = "nebula_session";
|
||||
/** Ohne Anmeldung erreichbar. */
|
||||
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
||||
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 {
|
||||
if (PUBLIC_PATHS.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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user