Compare commits
25
Commits
v0.4.0
..
ca26b9d0ce
@@ -43,9 +43,6 @@ yarn-error.log*
|
|||||||
/data
|
/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
|
||||||
/backups
|
|
||||||
|
|
||||||
# editors
|
# editors
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
+11
-70
@@ -172,38 +172,6 @@ Firmen-Gitea nebeneinander.
|
|||||||
|
|
||||||
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
||||||
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
||||||
|
|
||||||
### 4.4 Automatischer Abgleich
|
|
||||||
|
|
||||||
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
|
|
||||||
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
|
|
||||||
|
|
||||||
| Einstellung | Bedeutung |
|
|
||||||
| --- | --- |
|
|
||||||
| Abstand | 15 Minuten bis täglich |
|
|
||||||
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
|
|
||||||
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
|
|
||||||
|
|
||||||
Wie der Durchlauf arbeitet:
|
|
||||||
|
|
||||||
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
|
|
||||||
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
|
|
||||||
Cron. Zwei Durchläufe überschneiden sich nie.
|
|
||||||
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
|
|
||||||
nächsten Mal.
|
|
||||||
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
|
|
||||||
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
|
|
||||||
Token blockiert damit nicht dauerhaft das Kontingent.
|
|
||||||
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
|
|
||||||
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
|
|
||||||
synchronisieren geht unabhängig davon immer.
|
|
||||||
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
|
|
||||||
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
|
|
||||||
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
|
||||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
|
||||||
|
|
||||||
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
|
|
||||||
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
|
||||||
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
||||||
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
||||||
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
||||||
@@ -215,42 +183,6 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4a. Export, Import und Sicherung
|
|
||||||
|
|
||||||
### Projekte mitnehmen
|
|
||||||
|
|
||||||
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
|
|
||||||
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
|
|
||||||
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
|
|
||||||
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
|
|
||||||
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
|
|
||||||
bekannte Git-Stand als lesbare Datei.
|
|
||||||
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
|
|
||||||
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
|
|
||||||
|
|
||||||
| Strategie | Wirkung |
|
|
||||||
| --- | --- |
|
|
||||||
| Überspringen | Vorhandene bleiben unverändert |
|
|
||||||
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
|
|
||||||
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
|
|
||||||
|
|
||||||
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
|
|
||||||
hin. Erst ein zweiter Klick schreibt.
|
|
||||||
|
|
||||||
### Sicherung
|
|
||||||
|
|
||||||
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
|
|
||||||
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
|
|
||||||
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
|
|
||||||
kurzlebig, letztere hängen an Gerät und rpID.
|
|
||||||
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
|
|
||||||
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
|
|
||||||
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
|
|
||||||
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
|
|
||||||
ohne die echte anzufassen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Oberfläche
|
## 5. Oberfläche
|
||||||
|
|
||||||
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
|
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
|
||||||
@@ -338,5 +270,14 @@ Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
|
|||||||
|
|
||||||
## 7. Ideen für später
|
## 7. Ideen für später
|
||||||
|
|
||||||
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
|
Bewusst nicht umgesetzt, aber vorgemerkt:
|
||||||
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
|
|
||||||
|
- Kanban-Board mit Drag & Drop zwischen den Statusspalten
|
||||||
|
- Webhooks, damit Pushes den Snapshot ohne Knopfdruck aktualisieren
|
||||||
|
- Markdown-Rendering in Beschreibung und Notizen
|
||||||
|
- Volltextsuche über Notizen hinweg
|
||||||
|
- Export eines Projekts als Markdown
|
||||||
|
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
|
||||||
|
- Passwort-Zurücksetzen per E-Mail (aktuell setzt ein Administrator das Passwort neu)
|
||||||
|
- Zwei-Faktor per TOTP als Alternative zum Passkey
|
||||||
|
- Geteilte Projekte zwischen Konten (heute ist die Trennung absichtlich hart)
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ den Admin-Einstellungen.
|
|||||||
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
||||||
Access Tokens werden verschlüsselt abgelegt.
|
Access Tokens werden verschlüsselt abgelegt.
|
||||||
|
|
||||||
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
|
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**.
|
||||||
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,80 +181,8 @@ Nebula auffindbare Credentials nutzt.
|
|||||||
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
||||||
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
||||||
|
|
||||||
### Automatisch statt auf Knopfdruck
|
|
||||||
|
|
||||||
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
|
|
||||||
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
|
|
||||||
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
|
|
||||||
jeder Durchlauf API-Kontingent verbraucht.
|
|
||||||
|
|
||||||
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
|
|
||||||
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
|
|
||||||
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
|
|
||||||
Projektdialog ausnehmen; manuell synchronisieren geht immer.
|
|
||||||
|
|
||||||
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
|
||||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Export, Import und Sicherung
|
|
||||||
|
|
||||||
### Projekte mitnehmen
|
|
||||||
|
|
||||||
| Weg | Wo | Inhalt |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
|
|
||||||
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
|
|
||||||
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
|
|
||||||
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
|
|
||||||
|
|
||||||
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
|
|
||||||
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
|
|
||||||
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
|
|
||||||
angelegt und in der Vorschau angezeigt.
|
|
||||||
|
|
||||||
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
|
|
||||||
|
|
||||||
- **Überspringen** — Vorhandene bleiben unverändert
|
|
||||||
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
|
|
||||||
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
|
|
||||||
|
|
||||||
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
|
|
||||||
|
|
||||||
### Sicherung
|
|
||||||
|
|
||||||
Zwei Wege, mit unterschiedlichem Zweck:
|
|
||||||
|
|
||||||
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
|
|
||||||
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
|
|
||||||
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
|
|
||||||
vertraulich behandeln.
|
|
||||||
|
|
||||||
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
|
|
||||||
Instanz braucht:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
|
|
||||||
.\scripts\restore.ps1 -File .\backups\nebula-....sql
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
|
|
||||||
./scripts/restore.sh backups/nebula-....sql
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
|
|
||||||
Container aus — auf dem Rechner selbst muss nichts installiert sein.
|
|
||||||
|
|
||||||
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
|
|
||||||
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
|
|
||||||
>
|
|
||||||
> ```powershell
|
|
||||||
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
|
||||||
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
|
|
||||||
> ```
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -323,15 +250,10 @@ ausschließlich Daten des angemeldeten Kontos.
|
|||||||
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
||||||
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
||||||
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
||||||
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
|
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart und Registrierung (Admin) |
|
||||||
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
|
|
||||||
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
||||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||||
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
|
|
||||||
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
|
|
||||||
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
|
|
||||||
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
|
|
||||||
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
|
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
|
||||||
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
|
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
|
||||||
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
|
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
|
||||||
|
|||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
|
|
||||||
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
|
||||||
|
|
||||||
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
|
|
||||||
|
|
||||||
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1 · Als Nächstes empfohlen
|
|
||||||
|
|
||||||
### 1.1 Markdown in Beschreibung und Notizen `S`
|
|
||||||
|
|
||||||
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
|
|
||||||
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
|
|
||||||
werden klickbar.
|
|
||||||
|
|
||||||
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
|
|
||||||
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
|
|
||||||
|
|
||||||
### 1.2 Volltextsuche über Notizen `S`
|
|
||||||
|
|
||||||
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
|
|
||||||
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
|
|
||||||
zusätzlichen Dienst.
|
|
||||||
|
|
||||||
### 1.3 Kanban-Board mit Drag & Drop `M`
|
|
||||||
|
|
||||||
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
|
|
||||||
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
|
|
||||||
|
|
||||||
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
|
|
||||||
bleibt die Listenansicht.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2 · Danach
|
|
||||||
|
|
||||||
### 2.1 Offene Pull Requests und Issues als Liste `S`
|
|
||||||
|
|
||||||
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
|
|
||||||
nützlicher — die Daten holen wir ohnehin schon ab.
|
|
||||||
|
|
||||||
### 2.2 CI-Status je Branch `M`
|
|
||||||
|
|
||||||
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
|
|
||||||
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
|
|
||||||
Endpunkt und einen zusätzlichen Token-Scope.
|
|
||||||
|
|
||||||
### 2.3 Webhooks statt Polling `M`
|
|
||||||
|
|
||||||
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
|
||||||
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
|
|
||||||
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
|
|
||||||
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
|
||||||
|
|
||||||
### 2.4 Commit-Aktivität als Verlauf `S`
|
|
||||||
|
|
||||||
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
|
|
||||||
da, es fehlt nur die Darstellung.
|
|
||||||
|
|
||||||
### 2.5 Aufgaben und Checklisten `M`
|
|
||||||
|
|
||||||
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
|
|
||||||
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
|
|
||||||
|
|
||||||
### 2.6 Benachrichtigungen `M`
|
|
||||||
|
|
||||||
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
|
|
||||||
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
|
|
||||||
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3 · Größere Brocken
|
|
||||||
|
|
||||||
### 3.1 Automatisierte Tests und CI `L`
|
|
||||||
|
|
||||||
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
|
|
||||||
Browser-Screenshots. Das gehört in eine Pipeline:
|
|
||||||
|
|
||||||
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
|
|
||||||
Backoff-Berechnung
|
|
||||||
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
|
|
||||||
Datentrennung
|
|
||||||
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
|
|
||||||
|
|
||||||
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
|
|
||||||
länger lebt: er macht alles Weitere billiger und sicherer.
|
|
||||||
|
|
||||||
### 3.2 Mehrere Repositories je Projekt `M`
|
|
||||||
|
|
||||||
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischentabelle
|
|
||||||
und Anpassungen in der Anzeige.
|
|
||||||
|
|
||||||
### 3.3 Zwei-Faktor per TOTP `M`
|
|
||||||
|
|
||||||
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
|
|
||||||
erreichbar ist und nicht jedes Gerät Passkeys kann.
|
|
||||||
|
|
||||||
### 3.4 Passwort-Zurücksetzen per E-Mail `M`
|
|
||||||
|
|
||||||
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
|
||||||
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
|
|
||||||
|
|
||||||
### 3.5 Mehrsprachigkeit `L`
|
|
||||||
|
|
||||||
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
|
|
||||||
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4 · Kleinigkeiten
|
|
||||||
|
|
||||||
| Idee | Aufwand | Nutzen |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
|
|
||||||
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
|
|
||||||
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
|
|
||||||
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
|
|
||||||
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
|
||||||
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
|
||||||
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
|
|
||||||
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
|
|
||||||
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 |
|
|
||||||
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5 · Bewusst nicht
|
|
||||||
|
|
||||||
Damit klar ist, was **nicht** kommt und warum:
|
|
||||||
|
|
||||||
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
|
||||||
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
|
||||||
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
|
|
||||||
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
|
|
||||||
Übersicht, keine Entwicklungsumgebung.
|
|
||||||
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
|
|
||||||
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
|
|
||||||
der Punkt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Erledigt
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
|
|
||||||
| GitHub, GitLab und Gitea | ✓ |
|
|
||||||
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
|
|
||||||
| Einzel- und Mehrbenutzerbetrieb | ✓ |
|
|
||||||
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
|
||||||
| Docker- und Standalone-Betrieb | ✓ |
|
|
||||||
| Automatischer Abgleich mit Zeitplan | ✓ |
|
|
||||||
| Export, Import und Sicherung | ✓ |
|
|
||||||
|
|
||||||
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
|
|
||||||
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
|
|
||||||
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
|
|
||||||
Generated
+3
-2460
File diff suppressed because it is too large
Load Diff
+3
-12
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.4.0",
|
"version": "0.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": {
|
||||||
@@ -23,17 +23,12 @@
|
|||||||
"docker:logs": "docker compose --profile full logs -f app",
|
"docker:logs": "docker compose --profile full logs -f app",
|
||||||
"docker:tools": "docker compose --profile tools up -d",
|
"docker:tools": "docker compose --profile tools up -d",
|
||||||
"setup": "node scripts/setup.mjs",
|
"setup": "node scripts/setup.mjs",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate"
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest"
|
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
@@ -41,9 +36,6 @@
|
|||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"rehype-sanitize": "^6.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"zod": "^3.25.28"
|
"zod": "^3.25.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -54,8 +46,7 @@
|
|||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3"
|
||||||
"vitest": "^4.1.10"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.11"
|
"node": ">=20.11"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
|
|
||||||
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
|
|
||||||
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "lastAutoSyncReport" JSONB;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
|
|
||||||
+1
-25
@@ -137,25 +137,7 @@ model AppSettings {
|
|||||||
allowRegistration Boolean @default(false)
|
allowRegistration Boolean @default(false)
|
||||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||||
rpName String @default("Nebula")
|
rpName String @default("Nebula")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
// ── Automatischer Abgleich mit den Git-Systemen ────────────
|
|
||||||
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
|
|
||||||
/// das soll eine bewusste Entscheidung sein.
|
|
||||||
autoSyncEnabled Boolean @default(false)
|
|
||||||
/// Abstand zwischen zwei Durchlaeufen in Minuten.
|
|
||||||
autoSyncIntervalMinutes Int @default(60)
|
|
||||||
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
|
|
||||||
/// noch etwas.
|
|
||||||
autoSyncActiveOnly Boolean @default(true)
|
|
||||||
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
|
|
||||||
/// einen Schlag in ein Rate-Limit laeuft.
|
|
||||||
autoSyncBatchSize Int @default(25)
|
|
||||||
|
|
||||||
lastAutoSyncAt DateTime?
|
|
||||||
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
|
||||||
lastAutoSyncReport Json?
|
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||||
@@ -225,12 +207,6 @@ model Project {
|
|||||||
|
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
syncError String?
|
syncError String?
|
||||||
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
|
||||||
/// einem Repo, dessen Token staendig Fehler wirft.
|
|
||||||
autoSync Boolean @default(true)
|
|
||||||
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
|
|
||||||
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
|
||||||
syncFailureCount 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.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
|
||||||
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
|
|
||||||
man zum Wiederherstellen einer kaputten Instanz braucht.
|
|
||||||
|
|
||||||
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
|
|
||||||
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
|
|
||||||
|
|
||||||
.PARAMETER OutDir
|
|
||||||
Zielverzeichnis. Vorgabe: .\backups
|
|
||||||
|
|
||||||
.PARAMETER Container
|
|
||||||
Name des Postgres-Containers. Vorgabe: nebula-db
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\backup.ps1
|
|
||||||
.\scripts\backup.ps1 -OutDir D:\Sicherungen
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
|
|
||||||
[string]$Container = "nebula-db"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
|
|
||||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
|
||||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
|
||||||
if (Test-Path $envFile) {
|
|
||||||
foreach ($line in Get-Content $envFile) {
|
|
||||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
|
||||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $settings["POSTGRES_USER"]
|
|
||||||
$database = $settings["POSTGRES_DB"]
|
|
||||||
|
|
||||||
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
|
||||||
|
|
||||||
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
|
|
||||||
$target = Join-Path $OutDir "nebula-$stamp.sql"
|
|
||||||
|
|
||||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
|
||||||
if ($running -notcontains $Container) {
|
|
||||||
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
|
|
||||||
|
|
||||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
|
||||||
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
|
|
||||||
|
|
||||||
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
|
|
||||||
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
|
|
||||||
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
|
||||||
#
|
|
||||||
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
|
||||||
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
|
|
||||||
#
|
|
||||||
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
out_dir="${1:-$root/backups}"
|
|
||||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
|
||||||
|
|
||||||
user="nebula"
|
|
||||||
database="nebula"
|
|
||||||
if [ -f "$root/.env" ]; then
|
|
||||||
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
|
|
||||||
value_of() {
|
|
||||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
|
||||||
"$root/.env" | tail -n 1
|
|
||||||
}
|
|
||||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
|
||||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
|
||||||
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
stamp="$(date +%Y-%m-%d-%H%M%S)"
|
|
||||||
target="$out_dir/nebula-$stamp.sql"
|
|
||||||
|
|
||||||
echo "Sichere Datenbank '$database' aus Container '$container'..."
|
|
||||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
|
||||||
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
|
|
||||||
|
|
||||||
size="$(du -h "$target" | cut -f1)"
|
|
||||||
echo "Fertig: $target ($size)"
|
|
||||||
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
|
|
||||||
fragt vorher nach, es sei denn, -Force ist gesetzt.
|
|
||||||
|
|
||||||
.PARAMETER Database
|
|
||||||
Zieldatenbank. Vorgabe: der Wert aus der .env.
|
|
||||||
|
|
||||||
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
|
|
||||||
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
|
|
||||||
|
|
||||||
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
|
||||||
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$File,
|
|
||||||
[string]$Database,
|
|
||||||
[string]$Container = "nebula-db",
|
|
||||||
[switch]$Force
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
|
|
||||||
|
|
||||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
|
||||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
|
||||||
if (Test-Path $envFile) {
|
|
||||||
foreach ($line in Get-Content $envFile) {
|
|
||||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
|
||||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $settings["POSTGRES_USER"]
|
|
||||||
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
|
|
||||||
|
|
||||||
if (-not $Force) {
|
|
||||||
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
|
|
||||||
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
|
|
||||||
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
|
||||||
if ($running -notcontains $Container) {
|
|
||||||
Write-Error "Container '$Container' laeuft nicht."
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Spiele '$File' ein..."
|
|
||||||
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
|
|
||||||
|
|
||||||
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
|
|
||||||
#
|
|
||||||
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
|
|
||||||
#
|
|
||||||
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
|
|
||||||
#
|
|
||||||
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
|
|
||||||
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
|
|
||||||
#
|
|
||||||
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
|
||||||
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
file="${1:-}"
|
|
||||||
force="${2:-}"
|
|
||||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
|
||||||
|
|
||||||
if [ -z "$file" ]; then
|
|
||||||
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
echo "Datei nicht gefunden: $file" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
user="nebula"
|
|
||||||
database="nebula"
|
|
||||||
if [ -f "$root/.env" ]; then
|
|
||||||
value_of() {
|
|
||||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
|
||||||
"$root/.env" | tail -n 1
|
|
||||||
}
|
|
||||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
|
||||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
|
||||||
fi
|
|
||||||
database="${NEBULA_DB_NAME:-$database}"
|
|
||||||
|
|
||||||
if [ "$force" != "--force" ]; then
|
|
||||||
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
|
|
||||||
read -r answer
|
|
||||||
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
|
||||||
echo "Container '$container' laeuft nicht." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Spiele '$file' ein..."
|
|
||||||
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
|
|
||||||
|
|
||||||
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
|
|
||||||
+3
-27
@@ -1,9 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import {
|
import { AdminManager, type AdminData } from "@/components/admin/AdminManager";
|
||||||
AdminManager,
|
|
||||||
type AdminData,
|
|
||||||
type AutoSyncReport,
|
|
||||||
} from "@/components/admin/AdminManager";
|
|
||||||
import { requireAdminPage } from "@/lib/auth/guard";
|
import { requireAdminPage } from "@/lib/auth/guard";
|
||||||
import { getAppSettings } from "@/lib/auth/settings";
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
import { userSafeSelect } from "@/lib/auth/users";
|
import { userSafeSelect } from "@/lib/auth/users";
|
||||||
@@ -19,23 +15,12 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const admin = await requireAdminPage();
|
const admin = await requireAdminPage();
|
||||||
|
|
||||||
const [settings, users, eligible] = await Promise.all([
|
const [settings, users] = await Promise.all([
|
||||||
getAppSettings(),
|
getAppSettings(),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
select: userSafeSelect,
|
select: userSafeSelect,
|
||||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||||
}),
|
}),
|
||||||
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
|
||||||
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
|
||||||
prisma.project.count({
|
|
||||||
where: {
|
|
||||||
autoSync: true,
|
|
||||||
OR: [
|
|
||||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
|
||||||
{ repoUrl: { not: null } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data: AdminData = {
|
const data: AdminData = {
|
||||||
@@ -45,15 +30,6 @@ export default async function AdminPage() {
|
|||||||
currentUserId: admin.id,
|
currentUserId: admin.id,
|
||||||
appUrl: APP_URL,
|
appUrl: APP_URL,
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
autoSync: {
|
|
||||||
enabled: settings.autoSyncEnabled,
|
|
||||||
intervalMinutes: settings.autoSyncIntervalMinutes,
|
|
||||||
activeOnly: settings.autoSyncActiveOnly,
|
|
||||||
batchSize: settings.autoSyncBatchSize,
|
|
||||||
lastRunAt: settings.lastAutoSyncAt,
|
|
||||||
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
|
|
||||||
eligible,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +42,7 @@ export default async function AdminPage() {
|
|||||||
Admin-Einstellungen
|
Admin-Einstellungen
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
Betriebsart, Registrierung und Benutzerkonten.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { exportFileName } from "@/lib/export/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vollstaendige Sicherung der Instanz als JSON.
|
|
||||||
*
|
|
||||||
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
|
|
||||||
* deshalb auch Konten und Verbindungen. Was drinsteht:
|
|
||||||
*
|
|
||||||
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
|
|
||||||
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
|
|
||||||
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
|
|
||||||
* Passkeys haengen ohnehin am Geraet und an der rpID
|
|
||||||
*
|
|
||||||
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
|
|
||||||
* der Datenbank siehe scripts/backup.* mit pg_dump.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const [settings, users, connections, projects] = await Promise.all([
|
|
||||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
|
||||||
prisma.user.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true,
|
|
||||||
passwordHash: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: true,
|
|
||||||
lastLoginAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { username: "asc" },
|
|
||||||
}),
|
|
||||||
prisma.gitConnection.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
ownerId: true,
|
|
||||||
name: true,
|
|
||||||
provider: true,
|
|
||||||
baseUrl: true,
|
|
||||||
username: true,
|
|
||||||
tokenCipher: true,
|
|
||||||
tokenHint: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
}),
|
|
||||||
prisma.project.findMany({
|
|
||||||
include: {
|
|
||||||
notes: { orderBy: { createdAt: "asc" } },
|
|
||||||
// Snapshots bleiben draussen: reproduzierbar und gross.
|
|
||||||
},
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
kind: "nebula.backup",
|
|
||||||
version: 1,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
app: APP_NAME,
|
|
||||||
note:
|
|
||||||
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
|
|
||||||
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
|
|
||||||
settings,
|
|
||||||
users,
|
|
||||||
connections,
|
|
||||||
projects,
|
|
||||||
};
|
|
||||||
|
|
||||||
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(payload, null, 2), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -30,14 +30,7 @@ export async function PATCH(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { mode, allowRegistration } = parsed.data;
|
||||||
mode,
|
|
||||||
allowRegistration,
|
|
||||||
autoSyncEnabled,
|
|
||||||
autoSyncIntervalMinutes,
|
|
||||||
autoSyncActiveOnly,
|
|
||||||
autoSyncBatchSize,
|
|
||||||
} = parsed.data;
|
|
||||||
|
|
||||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||||
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
||||||
@@ -52,27 +45,18 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoSyncFields = {
|
|
||||||
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
|
||||||
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
|
|
||||||
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
|
|
||||||
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = await prisma.appSettings.upsert({
|
const settings = await prisma.appSettings.upsert({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
create: {
|
create: {
|
||||||
id: "singleton",
|
id: "singleton",
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
...autoSyncFields,
|
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||||
...autoSyncFields,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
|
||||||
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
|
|
||||||
*
|
|
||||||
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
|
|
||||||
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
|
|
||||||
* eingebauten Automatismus zu nutzen.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const limited = rateLimit(
|
|
||||||
`sync-run:${clientIp(request)}`,
|
|
||||||
LIMITS.outbound.limit,
|
|
||||||
LIMITS.outbound.windowMs,
|
|
||||||
);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
if (isAutoSyncRunning()) {
|
|
||||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = await runAutoSyncPass("manuell");
|
|
||||||
if (!report) {
|
|
||||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ report });
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,6 @@ export async function POST(request: Request) {
|
|||||||
email: email ?? null,
|
email: email ?? null,
|
||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: "USER",
|
role: "USER",
|
||||||
// Die Registrierung meldet direkt an.
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ export async function POST(request: Request) {
|
|||||||
email: email ?? null,
|
email: email ?? null,
|
||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
|
||||||
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { exportFileName } from "@/lib/export/format";
|
|
||||||
import { buildExportFile, toMarkdown } from "@/lib/export/project";
|
|
||||||
import { jsonError } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ein einzelnes Projekt exportieren.
|
|
||||||
*
|
|
||||||
* ?format=json (Vorgabe) – zum Wiedereinlesen
|
|
||||||
* ?format=md – lesbar, zum Archivieren
|
|
||||||
*/
|
|
||||||
export async function GET(request: Request, { params }: Params) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const format = new URL(request.url).searchParams.get("format") ?? "json";
|
|
||||||
|
|
||||||
const project = await prisma.project.findFirst({
|
|
||||||
where: { id, ownerId: auth.user.id },
|
|
||||||
include: {
|
|
||||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
|
||||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
|
||||||
snapshot: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
|
||||||
|
|
||||||
if (format === "md" || format === "markdown") {
|
|
||||||
const filename = exportFileName(project.slug, new Date(), "md");
|
|
||||||
return new Response(toMarkdown(project), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/markdown; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildExportFile([project]);
|
|
||||||
const filename = exportFileName(project.slug, new Date(), "json");
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(payload, null, 2), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -74,7 +74,6 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
"accent",
|
"accent",
|
||||||
"tags",
|
"tags",
|
||||||
"favorite",
|
"favorite",
|
||||||
"autoSync",
|
|
||||||
"connectionId",
|
"connectionId",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (data[key] !== undefined) update[key] = data[key];
|
if (data[key] !== undefined) update[key] = data[key];
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
import { exportFileName } from "@/lib/export/format";
|
|
||||||
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
|
|
||||||
import { jsonError } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Alle eigenen Projekte als JSON zum Herunterladen. */
|
|
||||||
export async function GET() {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const projects = await loadProjectsForExport(auth.user.id);
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildExportFile(projects);
|
|
||||||
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(payload, null, 2), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
|
|
||||||
import { uniqueSlug } from "@/lib/projects";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import {
|
|
||||||
IMPORT_MAX_BODY_BYTES,
|
|
||||||
clientIp,
|
|
||||||
jsonError,
|
|
||||||
readJsonBody,
|
|
||||||
tooManyRequests,
|
|
||||||
} from "@/lib/security/request";
|
|
||||||
import { slugify } from "@/lib/utils";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
|
||||||
data: z.unknown(),
|
|
||||||
/**
|
|
||||||
* skip – vorhandene Projekte unveraendert lassen
|
|
||||||
* rename – zusaetzlich anlegen, mit freiem Slug
|
|
||||||
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
|
|
||||||
*/
|
|
||||||
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
|
|
||||||
/** Nur pruefen und berichten, nichts schreiben. */
|
|
||||||
dryRun: z.boolean().default(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
|
|
||||||
|
|
||||||
interface ItemResult {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
action: Action;
|
|
||||||
/** Bei "rename" der tatsaechlich vergebene Slug. */
|
|
||||||
newSlug?: string;
|
|
||||||
notes: number;
|
|
||||||
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
|
|
||||||
connection: "matched" | "missing" | "none";
|
|
||||||
connectionName?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const limited = rateLimit(
|
|
||||||
`import:${clientIp(request)}`,
|
|
||||||
LIMITS.mutation.limit,
|
|
||||||
LIMITS.mutation.windowMs,
|
|
||||||
);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const outer = requestSchema.safeParse(body.data);
|
|
||||||
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
|
|
||||||
|
|
||||||
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
|
|
||||||
// damit passiert.
|
|
||||||
const parsed = exportFileSchema.safeParse(outer.data.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
const first = parsed.error.issues[0];
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
"Die Datei passt nicht zum erwarteten Format. " +
|
|
||||||
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
|
|
||||||
},
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { strategy, dryRun } = outer.data;
|
|
||||||
const file = parsed.data;
|
|
||||||
|
|
||||||
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
|
|
||||||
const connections = await prisma.gitConnection.findMany({
|
|
||||||
where: { ownerId: auth.user.id },
|
|
||||||
select: { id: true, name: true, provider: true, baseUrl: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
function matchConnection(described: ExportedProject["connection"]) {
|
|
||||||
if (!described) return null;
|
|
||||||
const byName = connections.find(
|
|
||||||
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (byName) return byName;
|
|
||||||
return (
|
|
||||||
connections.find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.provider === described.provider &&
|
|
||||||
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
|
|
||||||
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
|
|
||||||
) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: ItemResult[] = [];
|
|
||||||
|
|
||||||
for (const incoming of file.projects) {
|
|
||||||
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
|
||||||
const connection = matchConnection(incoming.connection);
|
|
||||||
const connectionState: ItemResult["connection"] = incoming.connection
|
|
||||||
? connection
|
|
||||||
? "matched"
|
|
||||||
: "missing"
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
const existing = await prisma.project.findFirst({
|
|
||||||
where: { slug, ownerId: auth.user.id },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const base: ItemResult = {
|
|
||||||
name: incoming.name,
|
|
||||||
slug,
|
|
||||||
action: "create",
|
|
||||||
notes: incoming.notes.length,
|
|
||||||
connection: connectionState,
|
|
||||||
connectionName: incoming.connection?.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing && strategy === "skip") {
|
|
||||||
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
results.push({
|
|
||||||
...base,
|
|
||||||
action,
|
|
||||||
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
|
|
||||||
...(action === "overwrite"
|
|
||||||
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
name: incoming.name,
|
|
||||||
summary: incoming.summary ?? null,
|
|
||||||
description: incoming.description ?? null,
|
|
||||||
status: incoming.status,
|
|
||||||
priority: incoming.priority,
|
|
||||||
progress: incoming.progress,
|
|
||||||
accent: incoming.accent,
|
|
||||||
tags: incoming.tags,
|
|
||||||
favorite: incoming.favorite,
|
|
||||||
autoSync: incoming.autoSync,
|
|
||||||
repoUrl: incoming.repoUrl ?? null,
|
|
||||||
repoOwner: incoming.repoOwner ?? null,
|
|
||||||
repoName: incoming.repoName ?? null,
|
|
||||||
connectionId: connection?.id ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const notes = incoming.notes.map((note) => ({
|
|
||||||
title: note.title ?? null,
|
|
||||||
content: note.content,
|
|
||||||
pinned: note.pinned ?? false,
|
|
||||||
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
|
|
||||||
? { createdAt: new Date(note.createdAt) }
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (existing && action === "overwrite") {
|
|
||||||
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
|
|
||||||
// erneuter Import erzeugt keine Dubletten.
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
|
||||||
prisma.project.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: { ...data, slug, notes: { create: notes } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
results.push({ ...base, action: "overwrite" });
|
|
||||||
} else {
|
|
||||||
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
|
|
||||||
await prisma.project.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
ownerId: auth.user.id,
|
|
||||||
slug: finalSlug,
|
|
||||||
notes: { create: notes },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.push({
|
|
||||||
...base,
|
|
||||||
action: existing ? "rename" : "create",
|
|
||||||
...(existing ? { newSlug: finalSlug } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
results.push({
|
|
||||||
...base,
|
|
||||||
action: "error",
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = {
|
|
||||||
total: results.length,
|
|
||||||
created: results.filter((entry) => entry.action === "create").length,
|
|
||||||
renamed: results.filter((entry) => entry.action === "rename").length,
|
|
||||||
overwritten: results.filter((entry) => entry.action === "overwrite").length,
|
|
||||||
skipped: results.filter((entry) => entry.action === "skip").length,
|
|
||||||
errors: results.filter((entry) => entry.action === "error").length,
|
|
||||||
missingConnections: results.filter((entry) => entry.connection === "missing").length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json({ dryRun, strategy, summary, results });
|
|
||||||
}
|
|
||||||
@@ -96,7 +96,6 @@ export async function POST(request: Request) {
|
|||||||
accent: data.accent,
|
accent: data.accent,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
favorite: data.favorite,
|
favorite: data.favorite,
|
||||||
autoSync: data.autoSync,
|
|
||||||
repoUrl: data.repoUrl ?? null,
|
repoUrl: data.repoUrl ?? null,
|
||||||
repoOwner,
|
repoOwner,
|
||||||
repoName,
|
repoName,
|
||||||
|
|||||||
@@ -69,151 +69,6 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge.
|
|
||||||
*
|
|
||||||
* Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal
|
|
||||||
* was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter
|
|
||||||
* durch. backdrop-filter hilft hier nicht: liegt das Element in einem
|
|
||||||
* Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die
|
|
||||||
* Weichzeichnung wirkungslos.
|
|
||||||
*/
|
|
||||||
.popover {
|
|
||||||
/* Vollstaendig deckend: bei 97 % schimmerte darunterliegender Text
|
|
||||||
(die Schaltflaechen "Bearbeiten" und "Loeschen") noch durch. */
|
|
||||||
background: #090b18;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 0 rgba(255, 255, 255, 0.07) inset,
|
|
||||||
0 28px 70px -24px rgba(0, 0, 0, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown-Inhalte. Tailwind setzt in seinem Preflight alle Abstaende und
|
|
||||||
* Schriftgroessen zurueck, also muessen Ueberschriften, Listen und
|
|
||||||
* Codebloecke hier wieder Form bekommen.
|
|
||||||
*/
|
|
||||||
.md {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: #cbd5e1;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md > *:first-child { margin-top: 0; }
|
|
||||||
.md > *:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
.md h1, .md h2, .md h3, .md h4 {
|
|
||||||
color: #f1f5f9;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
margin: 1.4em 0 0.5em;
|
|
||||||
}
|
|
||||||
.md h1 { font-size: 1.3rem; }
|
|
||||||
.md h2 { font-size: 1.15rem; }
|
|
||||||
.md h3 { font-size: 1rem; }
|
|
||||||
.md h4 { font-size: 0.9rem; }
|
|
||||||
|
|
||||||
.md p { margin: 0.75em 0; }
|
|
||||||
|
|
||||||
.md a {
|
|
||||||
color: #c4b5fd;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
.md a:hover { color: #ddd6fe; }
|
|
||||||
|
|
||||||
.md strong { color: #f1f5f9; font-weight: 600; }
|
|
||||||
|
|
||||||
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
|
|
||||||
.md ul { list-style: disc; }
|
|
||||||
.md ol { list-style: decimal; }
|
|
||||||
.md li { margin: 0.3em 0; }
|
|
||||||
.md li::marker { color: #64748b; }
|
|
||||||
|
|
||||||
/* Aufgabenlisten aus "- [ ] …" */
|
|
||||||
.md li:has(> input[type="checkbox"]) {
|
|
||||||
list-style: none;
|
|
||||||
margin-left: -1.35em;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.md input[type="checkbox"] {
|
|
||||||
margin-top: 0.35em;
|
|
||||||
accent-color: #8b5cf6;
|
|
||||||
width: 0.9rem;
|
|
||||||
height: 0.9rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md code {
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
padding: 0.1em 0.35em;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: #e9d5ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md pre {
|
|
||||||
background: rgba(4, 6, 16, 0.75);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
margin: 0.9em 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.md pre code {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md blockquote {
|
|
||||||
border-left: 2px solid rgba(167, 139, 250, 0.5);
|
|
||||||
padding-left: 0.9rem;
|
|
||||||
margin: 0.9em 0;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md hr {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
|
||||||
margin: 1.4em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md table {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.9em 0;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.md th, .md td {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.md th {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Text & Akzente ───────────────────────────────────────── */
|
/* ── Text & Akzente ───────────────────────────────────────── */
|
||||||
|
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
@@ -335,61 +190,4 @@
|
|||||||
background: #0a0c1a;
|
background: #0a0c1a;
|
||||||
color: #e6e8f5;
|
color: #e6e8f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Schieberegler. Der native Regler zeichnet die Spur rechts vom Griff in
|
|
||||||
* einem hellen Standardton - im dunklen Layout ein grelles Band. Deshalb
|
|
||||||
* vollstaendig selbst gezeichnet: der gefuellte Anteil kommt aus der
|
|
||||||
* Variablen --fill, die die Komponente aus dem Wert setzt.
|
|
||||||
*/
|
|
||||||
.range {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
#8b5cf6 0%,
|
|
||||||
#d946ef var(--fill, 0%),
|
|
||||||
rgba(255, 255, 255, 0.1) var(--fill, 0%),
|
|
||||||
rgba(255, 255, 255, 0.1) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.range:focus-visible {
|
|
||||||
outline: 2px solid rgba(167, 139, 250, 0.8);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #f5f3ff;
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-moz-range-thumb {
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #f5f3ff;
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-moz-range-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -63,9 +63,7 @@ export default async function DashboardPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Ideen"
|
label="Ideen"
|
||||||
value={ideas}
|
value={ideas}
|
||||||
// Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
|
hint={STATUS_META.IDEA.description}
|
||||||
// der schmalen Kachel mitten im Wort abgeschnitten.
|
|
||||||
hint="Noch nichts gebaut"
|
|
||||||
icon={Lightbulb}
|
icon={Lightbulb}
|
||||||
color={STATUS_META.IDEA.color}
|
color={STATUS_META.IDEA.color}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { GitPanel } from "@/components/GitPanel";
|
import { GitPanel } from "@/components/GitPanel";
|
||||||
import { Markdown } from "@/components/Markdown";
|
|
||||||
import { NotesPanel } from "@/components/NotesPanel";
|
import { NotesPanel } from "@/components/NotesPanel";
|
||||||
import { ProjectHeader } from "@/components/ProjectHeader";
|
import { ProjectHeader } from "@/components/ProjectHeader";
|
||||||
import { requireUserPage } from "@/lib/auth/guard";
|
import { requireUserPage } from "@/lib/auth/guard";
|
||||||
@@ -67,7 +66,9 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
Beschreibung
|
Beschreibung
|
||||||
</h2>
|
</h2>
|
||||||
{detail.description ? (
|
{detail.description ? (
|
||||||
<Markdown className="mt-3">{detail.description}</Markdown>
|
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
|
||||||
|
{detail.description}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-sm text-slate-500">
|
<p className="mt-3 text-sm text-slate-500">
|
||||||
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
|
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowRight,
|
|
||||||
CheckCircle2,
|
|
||||||
FileJson,
|
|
||||||
Loader2,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Modal } from "@/components/ui/Modal";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Strategy = "skip" | "rename" | "overwrite";
|
|
||||||
|
|
||||||
interface ItemResult {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
action: "create" | "rename" | "overwrite" | "skip" | "error";
|
|
||||||
newSlug?: string;
|
|
||||||
notes: number;
|
|
||||||
connection: "matched" | "missing" | "none";
|
|
||||||
connectionName?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportResponse {
|
|
||||||
dryRun: boolean;
|
|
||||||
strategy: Strategy;
|
|
||||||
summary: {
|
|
||||||
total: number;
|
|
||||||
created: number;
|
|
||||||
renamed: number;
|
|
||||||
overwritten: number;
|
|
||||||
skipped: number;
|
|
||||||
errors: number;
|
|
||||||
missingConnections: number;
|
|
||||||
};
|
|
||||||
results: ItemResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRATEGIES: Array<{ value: Strategy; label: string; hint: string }> = [
|
|
||||||
{ value: "skip", label: "Ueberspringen", hint: "Vorhandene bleiben unveraendert." },
|
|
||||||
{ value: "rename", label: "Zusaetzlich anlegen", hint: "Doppelte bekommen einen freien Slug." },
|
|
||||||
{ value: "overwrite", label: "Ersetzen", hint: "Stammdaten und Notizen werden ueberschrieben." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ACTION_LABEL: Record<ItemResult["action"], { text: string; className: string }> = {
|
|
||||||
create: { text: "neu", className: "bg-emerald-500/12 text-emerald-200" },
|
|
||||||
rename: { text: "zusaetzlich", className: "bg-cyan-500/12 text-cyan-200" },
|
|
||||||
overwrite: { text: "ersetzt", className: "bg-amber-500/12 text-amber-200" },
|
|
||||||
skip: { text: "uebersprungen", className: "bg-slate-500/12 text-slate-300" },
|
|
||||||
error: { text: "Fehler", className: "bg-rose-500/12 text-rose-200" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
|
||||||
const [payload, setPayload] = useState<unknown>(null);
|
|
||||||
const [strategy, setStrategy] = useState<Strategy>("skip");
|
|
||||||
const [preview, setPreview] = useState<ImportResponse | null>(null);
|
|
||||||
const [done, setDone] = useState<ImportResponse | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setFileName(null);
|
|
||||||
setPayload(null);
|
|
||||||
setPreview(null);
|
|
||||||
setDone(null);
|
|
||||||
setError(null);
|
|
||||||
setStrategy("skip");
|
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
reset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFile(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setPreview(null);
|
|
||||||
setDone(null);
|
|
||||||
setFileName(file.name);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
setPayload(parsed);
|
|
||||||
await run(parsed, strategy, true);
|
|
||||||
} catch {
|
|
||||||
setPayload(null);
|
|
||||||
setError("Die Datei enthaelt kein gueltiges JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(data: unknown, chosen: Strategy, dryRun: boolean) {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/projects/import", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ data, strategy: chosen, dryRun }),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Import fehlgeschlagen.");
|
|
||||||
|
|
||||||
if (dryRun) setPreview(body as ImportResponse);
|
|
||||||
else {
|
|
||||||
setDone(body as ImportResponse);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeStrategy(next: Strategy) {
|
|
||||||
setStrategy(next);
|
|
||||||
if (payload) await run(payload, next, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = done?.summary ?? preview?.summary;
|
|
||||||
const results = done?.results ?? preview?.results ?? [];
|
|
||||||
|
|
||||||
// Wuerde der Import ueberhaupt etwas veraendern? Sonst waere der Knopf ein
|
|
||||||
// Angebot, das ins Leere laeuft.
|
|
||||||
const wouldChange = preview
|
|
||||||
? preview.summary.created + preview.summary.renamed + preview.summary.overwritten > 0
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={close}
|
|
||||||
width="xl"
|
|
||||||
title="Projekte importieren"
|
|
||||||
description="Eine zuvor exportierte JSON-Datei einlesen. Vor dem Schreiben gibt es eine Vorschau."
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Datei */}
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="application/json,.json"
|
|
||||||
onChange={pickFile}
|
|
||||||
className="hidden"
|
|
||||||
id="import-file"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost w-full justify-start py-3"
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<FileJson className="h-4 w-4 text-violet-300" />
|
|
||||||
{fileName ?? "JSON-Datei auswaehlen…"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Strategie */}
|
|
||||||
{payload ? (
|
|
||||||
<div>
|
|
||||||
<span className="field-label">Bei gleichem Slug</span>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
{STRATEGIES.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
disabled={busy || Boolean(done)}
|
|
||||||
onClick={() => changeStrategy(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border px-3 py-2.5 text-left transition-all disabled:opacity-60",
|
|
||||||
strategy === option.value
|
|
||||||
? "border-violet-400/60 bg-violet-500/12"
|
|
||||||
: "border-white/9 hover:border-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="block text-sm text-slate-100">{option.label}</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
|
||||||
{option.hint}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Vorschau oder Ergebnis */}
|
|
||||||
{summary ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span className="text-slate-400">
|
|
||||||
{done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e)
|
|
||||||
</span>
|
|
||||||
{summary.created > 0 ? <Chip tone="emerald">{summary.created} neu</Chip> : null}
|
|
||||||
{summary.renamed > 0 ? <Chip tone="cyan">{summary.renamed} zusaetzlich</Chip> : null}
|
|
||||||
{summary.overwritten > 0 ? (
|
|
||||||
<Chip tone="amber">{summary.overwritten} ersetzt</Chip>
|
|
||||||
) : null}
|
|
||||||
{summary.skipped > 0 ? <Chip tone="slate">{summary.skipped} uebersprungen</Chip> : null}
|
|
||||||
{summary.errors > 0 ? <Chip tone="rose">{summary.errors} Fehler</Chip> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{summary.missingConnections > 0 ? (
|
|
||||||
<p className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
|
||||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{summary.missingConnections} Projekt(e) verweisen auf eine Git-Verbindung,
|
|
||||||
die hier nicht existiert. Sie werden ohne Verknuepfung angelegt – lege die
|
|
||||||
Verbindung an und waehle sie im Projekt aus. Access Tokens sind in
|
|
||||||
Exportdateien grundsaetzlich nicht enthalten.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/8">
|
|
||||||
{results.map((item) => {
|
|
||||||
const label = ACTION_LABEL[item.action];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${item.slug}-${item.name}`}
|
|
||||||
className="flex items-center justify-between gap-3 border-b border-white/5 px-3 py-2 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm text-slate-200">{item.name}</p>
|
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-slate-500">
|
|
||||||
<span>{item.slug}</span>
|
|
||||||
{item.newSlug ? (
|
|
||||||
<>
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
<span className="text-cyan-300">{item.newSlug}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<span className="text-slate-600">· {item.notes} Notizen</span>
|
|
||||||
{item.connection === "missing" ? (
|
|
||||||
<span className="text-amber-300/80">
|
|
||||||
· Verbindung „{item.connectionName}“ fehlt
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
{item.message ? (
|
|
||||||
<p className="mt-0.5 text-[11px] text-slate-600">{item.message}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
|
|
||||||
label.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{done ? (
|
|
||||||
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
Import abgeschlossen.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
|
|
||||||
{done ? "Schliessen" : "Abbrechen"}
|
|
||||||
</button>
|
|
||||||
{!done ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={busy || !wouldChange}
|
|
||||||
title={
|
|
||||||
preview && !wouldChange
|
|
||||||
? "Mit dieser Strategie wuerde sich nichts aendern."
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => payload && run(payload, strategy, false)}
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
||||||
Import ausfuehren
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({
|
|
||||||
tone,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
tone: "emerald" | "cyan" | "amber" | "slate" | "rose";
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const tones = {
|
|
||||||
emerald: "bg-emerald-500/12 text-emerald-200",
|
|
||||||
cyan: "bg-cyan-500/12 text-cyan-200",
|
|
||||||
amber: "bg-amber-500/12 text-amber-200",
|
|
||||||
slate: "bg-slate-500/12 text-slate-300",
|
|
||||||
rose: "bg-rose-500/12 text-rose-200",
|
|
||||||
};
|
|
||||||
return <span className={cn("rounded px-1.5 py-0.5", tones[tone])}>{children}</span>;
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown-Darstellung fuer Beschreibungen und Notizen.
|
|
||||||
*
|
|
||||||
* Der Inhalt stammt vom Benutzer, wird aber unveraendert gerendert – ohne
|
|
||||||
* rehype-sanitize waere das ein XSS-Loch, sobald jemand rohes HTML in eine
|
|
||||||
* Notiz schreibt. Das Standardschema erlaubt nur harmlose Elemente; ergaenzt
|
|
||||||
* wird lediglich, was fuer Aufgabenlisten und Codebloecke noetig ist.
|
|
||||||
*/
|
|
||||||
const schema = {
|
|
||||||
...defaultSchema,
|
|
||||||
attributes: {
|
|
||||||
...defaultSchema.attributes,
|
|
||||||
// Checkboxen aus "- [ ] Aufgabe"
|
|
||||||
input: [...(defaultSchema.attributes?.input ?? []), "checked", "disabled", "type"],
|
|
||||||
// Sprachklassen wie "language-ts" fuer Codebloecke
|
|
||||||
code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Markdown({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: string | null | undefined;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
if (!children?.trim()) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("md", className)}>
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
|
||||||
components={{
|
|
||||||
// Links aus fremdem Inhalt immer in neuem Tab und ohne
|
|
||||||
// Zugriff auf das oeffnende Fenster.
|
|
||||||
a: ({ href, children: content }) => (
|
|
||||||
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
|
|
||||||
{content}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2, NotebookPen, Pencil, Pin, PinOff, Plus, Trash2, X } from "lucide-react";
|
import { Loader2, NotebookPen, Pencil, Pin, PinOff, Plus, Trash2, X } from "lucide-react";
|
||||||
import type { Note } from "@prisma/client";
|
import type { Note } from "@prisma/client";
|
||||||
import { Markdown } from "@/components/Markdown";
|
|
||||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||||
|
|
||||||
/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */
|
/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */
|
||||||
@@ -119,7 +118,7 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
|
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
|
||||||
placeholder={"Gedanken, Todos, Snippets…\n\nMarkdown wird dargestellt:\n- [ ] offene Aufgabe\n**fett**, `code`, [Link](https://…)"}
|
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -162,7 +161,9 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
|
|||||||
{note.title}
|
{note.title}
|
||||||
</h3>
|
</h3>
|
||||||
) : null}
|
) : null}
|
||||||
<Markdown className="mt-1">{note.content}</Markdown>
|
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
|
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
|
||||||
{formatDateTime(note.createdAt)}
|
{formatDateTime(note.createdAt)}
|
||||||
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
|
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
|
||||||
Database,
|
|
||||||
Download,
|
|
||||||
LayoutGrid,
|
|
||||||
ListFilter,
|
|
||||||
Plus,
|
|
||||||
Rows3,
|
|
||||||
Search,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
import { ImportDialog } from "@/components/ImportDialog";
|
|
||||||
import { ProjectCard } from "@/components/ProjectCard";
|
import { ProjectCard } from "@/components/ProjectCard";
|
||||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
||||||
@@ -45,9 +33,6 @@ export function ProjectBoard({
|
|||||||
const [view, setView] = useState<ViewMode>("grid");
|
const [view, setView] = useState<ViewMode>("grid");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
|
||||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const map = new Map<ProjectStatus, number>();
|
const map = new Map<ProjectStatus, number>();
|
||||||
@@ -180,19 +165,6 @@ export function ProjectBoard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
ref={dataButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDataMenuOpen((value) => !value)}
|
|
||||||
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
|
|
||||||
aria-label="Daten exportieren oder importieren"
|
|
||||||
title="Export und Import"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={dataMenuOpen}
|
|
||||||
>
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
|
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Neues Projekt</span>
|
<span className="hidden sm:inline">Neues Projekt</span>
|
||||||
@@ -295,54 +267,11 @@ export function ProjectBoard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Popover
|
|
||||||
anchorRef={dataButtonRef}
|
|
||||||
open={dataMenuOpen}
|
|
||||||
onClose={() => setDataMenuOpen(false)}
|
|
||||||
width={260}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/api/projects/export"
|
|
||||||
download
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setDataMenuOpen(false)}
|
|
||||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<Download className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
|
||||||
<span>
|
|
||||||
Alle Projekte exportieren
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
JSON mit Notizen, ohne Access Tokens
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => {
|
|
||||||
setDataMenuOpen(false);
|
|
||||||
setImportOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<Upload className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
|
||||||
<span>
|
|
||||||
Projekte importieren
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Mit Vorschau vor dem Schreiben
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => setDialogOpen(false)}
|
onClose={() => setDialogOpen(false)}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ interface FormState {
|
|||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
repoOwner: string;
|
repoOwner: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
autoSync: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -48,7 +47,6 @@ const EMPTY: FormState = {
|
|||||||
repoUrl: "",
|
repoUrl: "",
|
||||||
repoOwner: "",
|
repoOwner: "",
|
||||||
repoName: "",
|
repoName: "",
|
||||||
autoSync: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
||||||
@@ -89,7 +87,6 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
repoUrl: project.repoUrl ?? "",
|
repoUrl: project.repoUrl ?? "",
|
||||||
repoOwner: project.repoOwner ?? "",
|
repoOwner: project.repoOwner ?? "",
|
||||||
repoName: project.repoName ?? "",
|
repoName: project.repoName ?? "",
|
||||||
autoSync: project.autoSync,
|
|
||||||
}
|
}
|
||||||
: EMPTY,
|
: EMPTY,
|
||||||
);
|
);
|
||||||
@@ -157,7 +154,6 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
repoUrl: form.repoUrl,
|
repoUrl: form.repoUrl,
|
||||||
repoOwner: form.repoOwner,
|
repoOwner: form.repoOwner,
|
||||||
repoName: form.repoName,
|
repoName: form.repoName,
|
||||||
autoSync: form.autoSync,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -291,8 +287,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
step={5}
|
step={5}
|
||||||
value={form.progress}
|
value={form.progress}
|
||||||
onChange={(event) => update("progress", Number(event.target.value))}
|
onChange={(event) => update("progress", Number(event.target.value))}
|
||||||
className="range"
|
className="w-full accent-violet-500"
|
||||||
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,22 +413,6 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.autoSync}
|
|
||||||
onChange={(event) => update("autoSync", event.target.checked)}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Am automatischen Abgleich teilnehmen
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-600">
|
|
||||||
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
|
|
||||||
synchronisieren geht unabhaengig davon immer.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
||||||
|
|
||||||
{repoPickerOpen ? (
|
{repoPickerOpen ? (
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Download,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileJson,
|
|
||||||
FileText,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Star,
|
Star,
|
||||||
@@ -17,7 +14,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||||
import { StatusSelect } from "@/components/StatusSelect";
|
import { StatusSelect } from "@/components/StatusSelect";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||||
import { cn, formatDate } from "@/lib/utils";
|
import { cn, formatDate } from "@/lib/utils";
|
||||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||||
@@ -34,8 +30,6 @@ export function ProjectHeader({
|
|||||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
|
||||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const [favorite, setFavorite] = useState(project.favorite);
|
const [favorite, setFavorite] = useState(project.favorite);
|
||||||
const [progress, setProgress] = useState(project.progress);
|
const [progress, setProgress] = useState(project.progress);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -168,17 +162,6 @@ export function ProjectHeader({
|
|||||||
<div className="flex flex-col items-end gap-3">
|
<div className="flex flex-col items-end gap-3">
|
||||||
<StatusSelect projectId={project.id} status={project.status} />
|
<StatusSelect projectId={project.id} status={project.status} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
|
||||||
ref={exportButtonRef}
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
|
||||||
onClick={() => setExportOpen((value) => !value)}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={exportOpen}
|
|
||||||
title="Exportieren"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
className="btn btn-ghost px-3 py-1.5 text-xs"
|
||||||
@@ -210,62 +193,29 @@ export function ProjectHeader({
|
|||||||
<span>Fortschritt</span>
|
<span>Fortschritt</span>
|
||||||
<span className="tabular-nums text-slate-300">{progress}%</span>
|
<span className="tabular-nums text-slate-300">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
|
<div className="relative">
|
||||||
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
|
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
|
||||||
<input
|
<div
|
||||||
type="range"
|
className="accent-bar h-full rounded-full transition-[width] duration-300"
|
||||||
min={0}
|
style={{ width: `${Math.max(1, progress)}%` }}
|
||||||
max={100}
|
/>
|
||||||
step={5}
|
</div>
|
||||||
value={progress}
|
<input
|
||||||
onChange={(event) => setProgress(Number(event.target.value))}
|
type="range"
|
||||||
onMouseUp={() => patch({ progress })}
|
min={0}
|
||||||
onTouchEnd={() => patch({ progress })}
|
max={100}
|
||||||
onKeyUp={() => patch({ progress })}
|
step={5}
|
||||||
aria-label="Fortschritt anpassen"
|
value={progress}
|
||||||
className="range"
|
onChange={(event) => setProgress(Number(event.target.value))}
|
||||||
style={{ "--fill": `${progress}%` } as React.CSSProperties}
|
onMouseUp={() => patch({ progress })}
|
||||||
/>
|
onTouchEnd={() => patch({ progress })}
|
||||||
|
onKeyUp={() => patch({ progress })}
|
||||||
|
aria-label="Fortschritt anpassen"
|
||||||
|
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover
|
|
||||||
anchorRef={exportButtonRef}
|
|
||||||
open={exportOpen}
|
|
||||||
onClose={() => setExportOpen(false)}
|
|
||||||
width={250}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`/api/projects/${project.id}/export`}
|
|
||||||
download
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setExportOpen(false)}
|
|
||||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
|
||||||
<span>
|
|
||||||
Als JSON
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Zum Wiedereinlesen, mit Notizen
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`/api/projects/${project.id}/export?format=md`}
|
|
||||||
download
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setExportOpen(false)}
|
|
||||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
|
||||||
<span>
|
|
||||||
Als Markdown
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Lesbar, inklusive Git-Stand
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export function StatusSelect({
|
|||||||
onChanged,
|
onChanged,
|
||||||
}: StatusSelectProps) {
|
}: StatusSelectProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [current, setCurrent] = useState<ProjectStatus>(status);
|
const [current, setCurrent] = useState<ProjectStatus>(status);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -56,12 +54,10 @@ export function StatusSelect({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setOpen((value) => !value);
|
setOpen((value) => !value);
|
||||||
@@ -86,49 +82,60 @@ export function StatusSelect({
|
|||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popover
|
{open ? (
|
||||||
anchorRef={triggerRef}
|
<>
|
||||||
open={open}
|
<button
|
||||||
onClose={() => setOpen(false)}
|
type="button"
|
||||||
role="listbox"
|
aria-label="Auswahl schliessen"
|
||||||
width={264}
|
className="fixed inset-0 z-30 cursor-default"
|
||||||
>
|
onClick={(event) => {
|
||||||
{STATUS_ORDER.map((value) => {
|
event.preventDefault();
|
||||||
const option = STATUS_META[value];
|
event.stopPropagation();
|
||||||
const active = value === current;
|
setOpen(false);
|
||||||
return (
|
}}
|
||||||
<button
|
/>
|
||||||
key={value}
|
<div
|
||||||
type="button"
|
role="listbox"
|
||||||
role="option"
|
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
|
||||||
aria-selected={active}
|
>
|
||||||
onClick={(event) => {
|
{STATUS_ORDER.map((value) => {
|
||||||
event.preventDefault();
|
const option = STATUS_META[value];
|
||||||
event.stopPropagation();
|
const active = value === current;
|
||||||
void select(value);
|
return (
|
||||||
}}
|
<button
|
||||||
className={cn(
|
key={value}
|
||||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
type="button"
|
||||||
active ? "bg-white/8" : "hover:bg-white/6",
|
role="option"
|
||||||
)}
|
aria-selected={active}
|
||||||
>
|
onClick={(event) => {
|
||||||
<span
|
event.preventDefault();
|
||||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
event.stopPropagation();
|
||||||
style={{ backgroundColor: option.color }}
|
void select(value);
|
||||||
/>
|
}}
|
||||||
<span className="min-w-0 flex-1">
|
className={cn(
|
||||||
<span className="flex items-center gap-2 text-sm text-slate-100">
|
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||||
{option.label}
|
active ? "bg-white/8" : "hover:bg-white/5",
|
||||||
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
)}
|
||||||
</span>
|
>
|
||||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
<span
|
||||||
{option.description}
|
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||||
</span>
|
style={{ backgroundColor: option.color }}
|
||||||
</span>
|
/>
|
||||||
</button>
|
<span className="min-w-0 flex-1">
|
||||||
);
|
<span className="flex items-center gap-2 text-sm text-slate-100">
|
||||||
})}
|
{option.label}
|
||||||
</Popover>
|
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
||||||
</>
|
</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-40
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import type { SessionUser } from "@/lib/auth/session";
|
import type { SessionUser } from "@/lib/auth/session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -86,7 +85,23 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(event: MouseEvent) {
|
||||||
|
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -100,9 +115,8 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
const initials = label.slice(0, 2).toUpperCase();
|
const initials = label.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative" ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||||||
@@ -118,48 +132,53 @@ function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
{open ? (
|
||||||
<div className="border-b border-white/6 px-3 py-2">
|
<div
|
||||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
role="menu"
|
||||||
<p className="truncate text-[11px] text-slate-500">
|
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
|
||||||
@{user.username}
|
|
||||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/account"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
>
|
||||||
<UserRound className="h-4 w-4" />
|
<div className="border-b border-white/6 px-3 py-2">
|
||||||
Mein Konto
|
<p className="truncate text-sm text-slate-100">{label}</p>
|
||||||
</Link>
|
<p className="truncate text-[11px] text-slate-500">
|
||||||
|
@{user.username}
|
||||||
|
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{user.role === "ADMIN" ? (
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/account"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<UserRound className="h-4 w-4" />
|
||||||
Admin-Einstellungen
|
Mein Konto
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
{user.role === "ADMIN" ? (
|
||||||
type="button"
|
<Link
|
||||||
role="menuitem"
|
href="/admin"
|
||||||
onClick={logout}
|
role="menuitem"
|
||||||
disabled={busy}
|
onClick={() => setOpen(false)}
|
||||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
Abmelden
|
Admin-Einstellungen
|
||||||
</button>
|
</Link>
|
||||||
</Popover>
|
) : null}
|
||||||
</>
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={logout}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
|
||||||
Download,
|
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
ShieldAlert,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -40,16 +36,6 @@ export interface AdminUser {
|
|||||||
_count: { credentials: number; projects: number; sessions: number };
|
_count: { credentials: number; projects: number; sessions: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoSyncReport {
|
|
||||||
at: string;
|
|
||||||
trigger: "zeitplan" | "manuell";
|
|
||||||
candidates: number;
|
|
||||||
synced: number;
|
|
||||||
failed: number;
|
|
||||||
skipped: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminData {
|
export interface AdminData {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
@@ -57,16 +43,6 @@ export interface AdminData {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
passkeysAvailable: boolean;
|
passkeysAvailable: boolean;
|
||||||
autoSync: {
|
|
||||||
enabled: boolean;
|
|
||||||
intervalMinutes: number;
|
|
||||||
activeOnly: boolean;
|
|
||||||
batchSize: number;
|
|
||||||
lastRunAt: string | Date | null;
|
|
||||||
lastReport: AutoSyncReport | null;
|
|
||||||
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
|
|
||||||
eligible: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
type Notice = { ok: boolean; text: string } | null;
|
||||||
@@ -186,8 +162,6 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AutoSyncSection data={data.autoSync} />
|
|
||||||
|
|
||||||
{/* ── Instanz ─────────────────────────────────────── */}
|
{/* ── Instanz ─────────────────────────────────────── */}
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
<div className="mb-4 flex items-start gap-3">
|
<div className="mb-4 flex items-start gap-3">
|
||||||
@@ -229,40 +203,6 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Sicherung ───────────────────────────────────── */}
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-amber-500/12 text-amber-300">
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Sicherung
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 max-w-2xl text-sm text-slate-400">
|
|
||||||
Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten,
|
|
||||||
Verbindungen, Projekte und Notizen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/api/admin/backup" download className="btn btn-ghost">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Sicherung herunterladen
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
|
|
||||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens
|
|
||||||
sind nur mit demselben <code>APP_SECRET</code> wieder lesbar, Passwoerter
|
|
||||||
liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine
|
|
||||||
byteweise Sicherung der Datenbank gibt es <code>scripts/backup.ps1</code>{" "}
|
|
||||||
beziehungsweise <code>scripts/backup.sh</code> mit <code>pg_dump</code>.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Benutzer ────────────────────────────────────── */}
|
{/* ── Benutzer ────────────────────────────────────── */}
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -324,256 +264,6 @@ export function AdminManager({ data }: { data: AdminData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTERVALS = [
|
|
||||||
{ value: 15, label: "alle 15 Minuten" },
|
|
||||||
{ value: 30, label: "alle 30 Minuten" },
|
|
||||||
{ value: 60, label: "stuendlich" },
|
|
||||||
{ value: 180, label: "alle 3 Stunden" },
|
|
||||||
{ value: 360, label: "alle 6 Stunden" },
|
|
||||||
{ value: 720, label: "alle 12 Stunden" },
|
|
||||||
{ value: 1440, label: "taeglich" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [enabled, setEnabled] = useState(data.enabled);
|
|
||||||
const [interval, setIntervalMinutes] = useState(data.intervalMinutes);
|
|
||||||
const [activeOnly, setActiveOnly] = useState(data.activeOnly);
|
|
||||||
const [batchSize, setBatchSize] = useState(data.batchSize);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
async function patch(payload: Record<string, unknown>) {
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/settings", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
|
||||||
setNotice({ ok: true, text: "Gespeichert." });
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setEnabled(data.enabled);
|
|
||||||
setIntervalMinutes(data.intervalMinutes);
|
|
||||||
setActiveOnly(data.activeOnly);
|
|
||||||
setBatchSize(data.batchSize);
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runNow() {
|
|
||||||
setRunning(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/sync/run", { method: "POST" });
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen.");
|
|
||||||
const report = body.report as AutoSyncReport;
|
|
||||||
setNotice({
|
|
||||||
ok: true,
|
|
||||||
text:
|
|
||||||
`${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` +
|
|
||||||
`${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`,
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextRun =
|
|
||||||
enabled && data.lastRunAt
|
|
||||||
? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Automatischer Abgleich
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 max-w-xl text-sm text-slate-400">
|
|
||||||
Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle
|
|
||||||
Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was
|
|
||||||
ohnehin verknuepft ist.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
|
||||||
onClick={runNow}
|
|
||||||
disabled={running || data.eligible === 0}
|
|
||||||
title={data.eligible === 0 ? "Kein Projekt mit Repository verknuepft" : undefined}
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Jetzt abgleichen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setEnabled(event.target.checked);
|
|
||||||
void patch({ autoSyncEnabled: event.target.checked });
|
|
||||||
}}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm text-slate-200">Regelmaessig abgleichen</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Standardmaessig aus. Jeder Durchlauf verbraucht Kontingent bei GitHub,
|
|
||||||
GitLab und Gitea – ohne Token ist das schnell erschoepft.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{enabled ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="autosync-interval">
|
|
||||||
Abstand
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="autosync-interval"
|
|
||||||
className="field"
|
|
||||||
value={interval}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = Number(event.target.value);
|
|
||||||
setIntervalMinutes(value);
|
|
||||||
void patch({ autoSyncIntervalMinutes: value });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{INTERVALS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="autosync-batch">
|
|
||||||
Projekte je Durchlauf: {batchSize}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="autosync-batch"
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={batchSize}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => setBatchSize(Number(event.target.value))}
|
|
||||||
onMouseUp={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
onKeyUp={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
className="range mt-2"
|
|
||||||
style={{ "--fill": `${batchSize}%` } as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
|
||||||
Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="mt-3 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={activeOnly}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setActiveOnly(event.target.checked);
|
|
||||||
void patch({ autoSyncActiveOnly: event.target.checked });
|
|
||||||
}}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm text-slate-200">
|
|
||||||
Fertige und archivierte Projekte auslassen
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Dort aendert sich selten noch etwas.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Zustand */}
|
|
||||||
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Verknuepfte Projekte
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.eligible}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Letzter Durchlauf
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
|
||||||
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Naechster Durchlauf
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
|
||||||
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{data.lastReport ? (
|
|
||||||
<p className="mt-3 text-[11px] text-slate-500" suppressHydrationWarning>
|
|
||||||
Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "}
|
|
||||||
{data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt
|
|
||||||
in {Math.round(data.lastReport.durationMs / 1000)} s.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
|
||||||
notice.ok
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
|
||||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notice.text}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeCard({
|
function ModeCard({
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -15,14 +14,6 @@ interface ModalProps {
|
|||||||
width?: "md" | "lg" | "xl";
|
width?: "md" | "lg" | "xl";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog per Portal am body.
|
|
||||||
*
|
|
||||||
* Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten
|
|
||||||
* heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen
|
|
||||||
* Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am
|
|
||||||
* Fenster.
|
|
||||||
*/
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -32,10 +23,6 @@ export function Modal({
|
|||||||
footer,
|
footer,
|
||||||
width = "lg",
|
width = "lg",
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
@@ -50,22 +37,22 @@ export function Modal({
|
|||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!mounted || !open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dialog schliessen"
|
aria-label="Dialog schliessen"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 cursor-default bg-[#03040c]/85 backdrop-blur-sm"
|
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
|
"glass fade-up relative z-10 my-auto w-full p-6",
|
||||||
width === "md" && "max-w-lg",
|
width === "md" && "max-w-lg",
|
||||||
width === "lg" && "max-w-2xl",
|
width === "lg" && "max-w-2xl",
|
||||||
width === "xl" && "max-w-4xl",
|
width === "xl" && "max-w-4xl",
|
||||||
@@ -92,7 +79,6 @@ export function Modal({
|
|||||||
|
|
||||||
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
|
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schwebendes Menue, das per Portal am body haengt.
|
|
||||||
*
|
|
||||||
* Warum nicht einfach ein absolut positioniertes Kind?
|
|
||||||
*
|
|
||||||
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
|
|
||||||
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
|
|
||||||
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
|
|
||||||
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
|
|
||||||
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
|
|
||||||
* selbst position: fixed bezieht sich auf die Karte statt auf das
|
|
||||||
* Fenster.
|
|
||||||
*
|
|
||||||
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
|
|
||||||
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
|
|
||||||
*/
|
|
||||||
export function Popover({
|
|
||||||
anchorRef,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
align = "end",
|
|
||||||
width = 240,
|
|
||||||
className,
|
|
||||||
labelledBy,
|
|
||||||
role = "menu",
|
|
||||||
}: {
|
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
/** "end" richtet die rechte Kante am Anker aus. */
|
|
||||||
align?: "start" | "end";
|
|
||||||
width?: number;
|
|
||||||
className?: string;
|
|
||||||
labelledBy?: string;
|
|
||||||
role?: "menu" | "listbox" | "dialog";
|
|
||||||
}) {
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const place = useCallback(() => {
|
|
||||||
const anchor = anchorRef.current;
|
|
||||||
if (!anchor) return;
|
|
||||||
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
|
||||||
const margin = 8;
|
|
||||||
const gap = 6;
|
|
||||||
|
|
||||||
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
|
||||||
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
|
||||||
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
|
||||||
// Ausloeser.
|
|
||||||
let left = align === "end" ? rect.right - width : rect.left;
|
|
||||||
if (left < margin) left = rect.left;
|
|
||||||
if (left + width > window.innerWidth - margin) left = rect.right - width;
|
|
||||||
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
|
|
||||||
|
|
||||||
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
|
||||||
let top = rect.bottom + gap;
|
|
||||||
|
|
||||||
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
|
|
||||||
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
|
|
||||||
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
|
||||||
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
|
||||||
|
|
||||||
setPosition({ top, left });
|
|
||||||
}, [anchorRef, align, width]);
|
|
||||||
|
|
||||||
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
|
||||||
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
|
|
||||||
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setPosition(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
place();
|
|
||||||
}, [open, place]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
// capture: true, damit auch das Scrollen innerer Container ankommt.
|
|
||||||
const onScroll = () => place();
|
|
||||||
window.addEventListener("scroll", onScroll, true);
|
|
||||||
window.addEventListener("resize", onScroll);
|
|
||||||
|
|
||||||
function onPointerDown(event: MouseEvent) {
|
|
||||||
const target = event.target as Node;
|
|
||||||
if (panelRef.current?.contains(target)) return;
|
|
||||||
if (anchorRef.current?.contains(target)) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", onPointerDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", onScroll, true);
|
|
||||||
window.removeEventListener("resize", onScroll);
|
|
||||||
document.removeEventListener("mousedown", onPointerDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open, place, onClose, anchorRef]);
|
|
||||||
|
|
||||||
if (!mounted || !open) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
role={role}
|
|
||||||
aria-labelledby={labelledBy}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: position?.top ?? -9999,
|
|
||||||
left: position?.left ?? -9999,
|
|
||||||
width,
|
|
||||||
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
|
||||||
// Menue kurz an der falschen Stelle auf.
|
|
||||||
visibility: position ? "visible" : "hidden",
|
|
||||||
}}
|
|
||||||
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wird von Next einmal beim Start des Servers ausgefuehrt.
|
|
||||||
*
|
|
||||||
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
|
|
||||||
*
|
|
||||||
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
|
|
||||||
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
|
|
||||||
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
|
|
||||||
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
|
|
||||||
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
|
|
||||||
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
|
|
||||||
* Build schluege fehl.
|
|
||||||
*/
|
|
||||||
export async function register() {
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
const { describeConfig } = await import("@/lib/config");
|
|
||||||
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
|
|
||||||
|
|
||||||
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
|
|
||||||
startAutoSyncScheduler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Austauschformat fuer Projekte.
|
|
||||||
*
|
|
||||||
* Bewusst schlank und ohne Geheimnisse:
|
|
||||||
* - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
|
|
||||||
* Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
|
|
||||||
* Exportdatei landet sonst schnell in einem Download-Ordner oder einem
|
|
||||||
* Cloud-Abgleich.
|
|
||||||
* - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf;
|
|
||||||
* ein Abgleich nach dem Import holt sie zurueck.
|
|
||||||
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
|
|
||||||
* wurde.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const EXPORT_KIND = "nebula.projects";
|
|
||||||
export const EXPORT_VERSION = 1;
|
|
||||||
|
|
||||||
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
|
||||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
|
||||||
|
|
||||||
export const exportedNoteSchema = z.object({
|
|
||||||
title: z.string().max(160).nullable().optional(),
|
|
||||||
content: z.string().max(20000),
|
|
||||||
pinned: z.boolean().optional().default(false),
|
|
||||||
createdAt: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const exportedConnectionSchema = z.object({
|
|
||||||
name: z.string().max(80),
|
|
||||||
provider: z.enum(PROVIDERS),
|
|
||||||
baseUrl: z.string().max(300),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const exportedProjectSchema = z.object({
|
|
||||||
name: z.string().min(1).max(120),
|
|
||||||
slug: z.string().max(60).optional(),
|
|
||||||
summary: z.string().max(280).nullable().optional(),
|
|
||||||
description: z.string().max(20000).nullable().optional(),
|
|
||||||
status: z.enum(STATUS).optional().default("IDEA"),
|
|
||||||
priority: z.number().int().min(1).max(4).optional().default(2),
|
|
||||||
progress: z.number().int().min(0).max(100).optional().default(0),
|
|
||||||
accent: z.string().max(20).optional().default("violet"),
|
|
||||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
|
||||||
favorite: z.boolean().optional().default(false),
|
|
||||||
autoSync: z.boolean().optional().default(true),
|
|
||||||
repoUrl: z.string().max(500).nullable().optional(),
|
|
||||||
repoOwner: z.string().max(200).nullable().optional(),
|
|
||||||
repoName: z.string().max(200).nullable().optional(),
|
|
||||||
/** Nur zur Wiedererkennung – ohne Token. */
|
|
||||||
connection: exportedConnectionSchema.nullable().optional(),
|
|
||||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
|
||||||
createdAt: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const exportFileSchema = z.object({
|
|
||||||
kind: z.literal(EXPORT_KIND),
|
|
||||||
version: z.number().int().min(1).max(EXPORT_VERSION),
|
|
||||||
exportedAt: z.string().optional(),
|
|
||||||
app: z.string().optional(),
|
|
||||||
projects: z.array(exportedProjectSchema).min(1).max(1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
|
|
||||||
export type ExportFile = z.infer<typeof exportFileSchema>;
|
|
||||||
|
|
||||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
|
||||||
export function exportFileName(prefix: string, date: Date, extension: string): string {
|
|
||||||
const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
||||||
const safe = prefix
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 40);
|
|
||||||
return `${safe || "nebula"}-${stamp}.${extension}`;
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { PRIORITY_META, statusOf } from "@/lib/status";
|
|
||||||
import { formatDateTime } from "@/lib/utils";
|
|
||||||
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
|
|
||||||
|
|
||||||
type ProjectForExport = Project & {
|
|
||||||
notes: Note[];
|
|
||||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
|
||||||
export function toExportedProject(project: ProjectForExport): ExportedProject {
|
|
||||||
return {
|
|
||||||
name: project.name,
|
|
||||||
slug: project.slug,
|
|
||||||
summary: project.summary,
|
|
||||||
description: project.description,
|
|
||||||
status: project.status,
|
|
||||||
priority: project.priority,
|
|
||||||
progress: project.progress,
|
|
||||||
accent: project.accent,
|
|
||||||
tags: project.tags,
|
|
||||||
favorite: project.favorite,
|
|
||||||
autoSync: project.autoSync,
|
|
||||||
repoUrl: project.repoUrl,
|
|
||||||
repoOwner: project.repoOwner,
|
|
||||||
repoName: project.repoName,
|
|
||||||
connection: project.connection
|
|
||||||
? {
|
|
||||||
name: project.connection.name,
|
|
||||||
provider: project.connection.provider,
|
|
||||||
baseUrl: project.connection.baseUrl,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
notes: project.notes.map((note) => ({
|
|
||||||
title: note.title,
|
|
||||||
content: note.content,
|
|
||||||
pinned: note.pinned,
|
|
||||||
createdAt: note.createdAt.toISOString(),
|
|
||||||
})),
|
|
||||||
createdAt: project.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildExportFile(projects: ProjectForExport[]): ExportFile {
|
|
||||||
return {
|
|
||||||
kind: EXPORT_KIND,
|
|
||||||
version: EXPORT_VERSION,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
app: APP_NAME,
|
|
||||||
projects: projects.map(toExportedProject),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Laedt die Projekte eines Kontos im fuer den Export noetigen Umfang. */
|
|
||||||
export async function loadProjectsForExport(
|
|
||||||
ownerId: string,
|
|
||||||
projectId?: string,
|
|
||||||
): Promise<ProjectForExport[]> {
|
|
||||||
return prisma.project.findMany({
|
|
||||||
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
|
||||||
include: {
|
|
||||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
|
||||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
|
||||||
},
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PROVIDER_LABEL: Record<string, string> = {
|
|
||||||
GITHUB: "GitHub",
|
|
||||||
GITLAB: "GitLab",
|
|
||||||
GITEA: "Gitea",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
|
|
||||||
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
|
|
||||||
* Datei auch ohne Netz vollstaendig ist.
|
|
||||||
*/
|
|
||||||
export function toMarkdown(
|
|
||||||
project: ProjectForExport & { snapshot: RepoSnapshot | null },
|
|
||||||
): string {
|
|
||||||
const status = statusOf(project.status);
|
|
||||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(`# ${project.name}`, "");
|
|
||||||
if (project.summary) lines.push(`> ${project.summary}`, "");
|
|
||||||
|
|
||||||
lines.push("| | |", "| --- | --- |");
|
|
||||||
lines.push(`| Status | ${status.label} |`);
|
|
||||||
lines.push(`| Fortschritt | ${project.progress} % |`);
|
|
||||||
lines.push(`| Prioritaet | ${priority.label} |`);
|
|
||||||
if (project.tags.length) {
|
|
||||||
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
|
||||||
}
|
|
||||||
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
|
|
||||||
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
|
|
||||||
if (project.connection) {
|
|
||||||
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
|
|
||||||
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
if (project.description) {
|
|
||||||
lines.push("## Beschreibung", "", project.description, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = project.snapshot;
|
|
||||||
if (snapshot) {
|
|
||||||
lines.push("## Stand des Repositories", "");
|
|
||||||
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
|
|
||||||
lines.push("| | |", "| --- | --- |");
|
|
||||||
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
|
||||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
|
||||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
|
||||||
lines.push(`| Tags | ${snapshot.tagCount} |`);
|
|
||||||
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
|
|
||||||
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
|
|
||||||
if (snapshot.lastCommitSha) {
|
|
||||||
lines.push(
|
|
||||||
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
|
|
||||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (project.notes.length) {
|
|
||||||
lines.push("## Notizen", "");
|
|
||||||
for (const note of project.notes) {
|
|
||||||
const pin = note.pinned ? " 📌" : "";
|
|
||||||
lines.push(`### ${note.title ?? "Ohne Titel"}${pin}`, "");
|
|
||||||
lines.push(note.content, "");
|
|
||||||
lines.push(`<sub>${formatDateTime(note.createdAt)}</sub>`, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("---", "");
|
|
||||||
lines.push(`<sub>Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.</sub>`, "");
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */
|
/** Groesste akzeptierte JSON-Nutzlast. */
|
||||||
const MAX_BODY_BYTES = 256 * 1024;
|
const MAX_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
/**
|
|
||||||
* Groessere Obergrenze fuer den Import: eine Exportdatei mit vielen Notizen
|
|
||||||
* sprengt 256 KB schnell.
|
|
||||||
*/
|
|
||||||
export const IMPORT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
export function jsonError(
|
export function jsonError(
|
||||||
message: string,
|
message: string,
|
||||||
status: number,
|
status: number,
|
||||||
@@ -40,7 +34,6 @@ export type BodyResult<T> =
|
|||||||
*/
|
*/
|
||||||
export async function readJsonBody<T = unknown>(
|
export async function readJsonBody<T = unknown>(
|
||||||
request: Request,
|
request: Request,
|
||||||
maxBytes: number = MAX_BODY_BYTES,
|
|
||||||
): Promise<BodyResult<T>> {
|
): Promise<BodyResult<T>> {
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
if (!contentType.toLowerCase().includes("application/json")) {
|
if (!contentType.toLowerCase().includes("application/json")) {
|
||||||
@@ -51,7 +44,7 @@ export async function readJsonBody<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
||||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
||||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +55,7 @@ export async function readJsonBody<T = unknown>(
|
|||||||
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
|
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.length > maxBytes) {
|
if (raw.length > MAX_BODY_BYTES) {
|
||||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,12 +63,7 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
|||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: {
|
data: { syncError: message, lastSyncedAt: new Date() },
|
||||||
syncError: message,
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
|
|
||||||
syncFailureCount: { increment: 1 },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { ok: false, message };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
@@ -132,7 +127,6 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
|||||||
data: {
|
data: {
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
syncError: null,
|
syncError: null,
|
||||||
syncFailureCount: 0,
|
|
||||||
defaultBranch: data.defaultBranch,
|
defaultBranch: data.defaultBranch,
|
||||||
repoUrl: data.htmlUrl ?? undefined,
|
repoUrl: data.htmlUrl ?? undefined,
|
||||||
},
|
},
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { getAppSettings } from "@/lib/auth/settings";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { syncProject } from "@/lib/sync";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatischer Abgleich mit den Git-Systemen.
|
|
||||||
*
|
|
||||||
* Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer
|
|
||||||
* eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein
|
|
||||||
* zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration.
|
|
||||||
* Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run
|
|
||||||
* auf und laesst den Automatismus aus.
|
|
||||||
*
|
|
||||||
* Ruecksicht auf fremde Kontingente:
|
|
||||||
* - je Durchlauf nur eine begrenzte Zahl Projekte
|
|
||||||
* - die am laengsten nicht abgeglichenen zuerst
|
|
||||||
* - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand
|
|
||||||
* - kurze Pause zwischen den Projekten
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TICK_MS = 60_000;
|
|
||||||
/** Kleine Verschnaufpause zwischen zwei Projekten. */
|
|
||||||
const PAUSE_MS = 400;
|
|
||||||
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
|
|
||||||
const MAX_BACKOFF_STEPS = 4;
|
|
||||||
|
|
||||||
export interface AutoSyncReport {
|
|
||||||
at: string;
|
|
||||||
trigger: "zeitplan" | "manuell";
|
|
||||||
candidates: number;
|
|
||||||
synced: number;
|
|
||||||
failed: number;
|
|
||||||
/** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */
|
|
||||||
skipped: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei
|
|
||||||
// jedem Neuladen einen weiteren Zeitgeber startet.
|
|
||||||
const globalForScheduler = globalThis as unknown as {
|
|
||||||
nebulaAutoSyncTimer?: NodeJS.Timeout;
|
|
||||||
nebulaAutoSyncRunning?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function startAutoSyncScheduler() {
|
|
||||||
if (globalForScheduler.nebulaAutoSyncTimer) return;
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
void tick();
|
|
||||||
}, TICK_MS);
|
|
||||||
// Der Zeitgeber darf den Prozess nicht am Beenden hindern.
|
|
||||||
timer.unref?.();
|
|
||||||
globalForScheduler.nebulaAutoSyncTimer = timer;
|
|
||||||
|
|
||||||
// Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten.
|
|
||||||
const initial = setTimeout(() => void tick(), 20_000);
|
|
||||||
initial.unref?.();
|
|
||||||
|
|
||||||
console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */
|
|
||||||
async function tick() {
|
|
||||||
try {
|
|
||||||
const settings = await getAppSettings();
|
|
||||||
if (!settings.autoSyncEnabled) return;
|
|
||||||
|
|
||||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
|
||||||
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
|
|
||||||
if (Date.now() - last < intervalMs) return;
|
|
||||||
|
|
||||||
await runAutoSyncPass("zeitplan");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
|
|
||||||
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
|
|
||||||
*/
|
|
||||||
export async function runAutoSyncPass(
|
|
||||||
trigger: AutoSyncReport["trigger"],
|
|
||||||
): Promise<AutoSyncReport | null> {
|
|
||||||
if (globalForScheduler.nebulaAutoSyncRunning) return null;
|
|
||||||
globalForScheduler.nebulaAutoSyncRunning = true;
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
|
||||||
let synced = 0;
|
|
||||||
let failed = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = await getAppSettings();
|
|
||||||
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
|
|
||||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
|
||||||
|
|
||||||
const where: Prisma.ProjectWhereInput = {
|
|
||||||
autoSync: true,
|
|
||||||
// Ohne Repository gibt es nichts abzugleichen.
|
|
||||||
OR: [
|
|
||||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
|
||||||
{ repoUrl: { not: null } },
|
|
||||||
],
|
|
||||||
...(settings.autoSyncActiveOnly
|
|
||||||
? { status: { notIn: ["DONE", "ARCHIVED"] } }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
|
||||||
const candidates = await prisma.project.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
|
||||||
take: batchSize * 4,
|
|
||||||
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const due = candidates.filter((project) => {
|
|
||||||
if (project.syncFailureCount === 0) return true;
|
|
||||||
if (!project.lastSyncedAt) return true;
|
|
||||||
|
|
||||||
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
|
|
||||||
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS);
|
|
||||||
const wait = intervalMs * 2 ** steps;
|
|
||||||
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
|
|
||||||
if (!ready) skipped += 1;
|
|
||||||
return ready;
|
|
||||||
});
|
|
||||||
|
|
||||||
const batch = due.slice(0, batchSize);
|
|
||||||
|
|
||||||
for (const project of batch) {
|
|
||||||
const result = await syncProject(project.id);
|
|
||||||
if (result.ok) synced += 1;
|
|
||||||
else failed += 1;
|
|
||||||
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
|
||||||
}
|
|
||||||
|
|
||||||
const report: AutoSyncReport = {
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
trigger,
|
|
||||||
candidates: candidates.length,
|
|
||||||
synced,
|
|
||||||
failed,
|
|
||||||
skipped,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.appSettings.update({
|
|
||||||
where: { id: "singleton" },
|
|
||||||
data: {
|
|
||||||
lastAutoSyncAt: new Date(),
|
|
||||||
lastAutoSyncReport: report as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` +
|
|
||||||
`${skipped} zurueckgestellt, ${report.durationMs} ms`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return report;
|
|
||||||
} finally {
|
|
||||||
globalForScheduler.nebulaAutoSyncRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAutoSyncRunning(): boolean {
|
|
||||||
return Boolean(globalForScheduler.nebulaAutoSyncRunning);
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,6 @@ export const projectCreateSchema = z.object({
|
|||||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
||||||
/** Am automatischen Abgleich teilnehmen. */
|
|
||||||
autoSync: z.boolean().default(true),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||||
@@ -109,11 +107,6 @@ export const profileUpdateSchema = z.object({
|
|||||||
export const adminSettingsSchema = z.object({
|
export const adminSettingsSchema = z.object({
|
||||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
|
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
|
||||||
allowRegistration: z.boolean().optional(),
|
allowRegistration: z.boolean().optional(),
|
||||||
autoSyncEnabled: z.boolean().optional(),
|
|
||||||
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
|
|
||||||
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
|
||||||
autoSyncActiveOnly: z.boolean().optional(),
|
|
||||||
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminUserCreateSchema = z.object({
|
export const adminUserCreateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user