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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Generated
+20
-6
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
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 |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
|||||||
@@ -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 { 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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+177
-65
@@ -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}`}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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.
|
||||||
|
|||||||
@@ -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,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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user