5 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 f3c3383231 feat(board): Listenansicht, und die mobile Ansicht laeuft nicht mehr aus dem Bild
CI / pruefen (push) Canceled after 0s
Auf 375px war das Dashboard 40px breiter als der Bildschirm. Ursache war die
Steuerleiste: Suchfeld, Sortierung, Ansichtsumschalter und "Neues Projekt"
lagen in einer Flex-Zeile ohne Umbruch, und weil das Sortierfeld ein
min-width mitbringt, wurde der Knopf am Ende schlicht hinausgeschoben. Das
overflow-x: hidden am body hat ihn dann abgeschnitten statt erreichbar zu
lassen. Dasselbe Muster im Projektkopf, wo "Loeschen" ausserhalb lag.

Die Zeilen brechen jetzt um, der Ansichtsumschalter steht auf schmalen
Anzeigen ueber die volle Breite. Gemessen ueber alle Seiten und beide
Testbreiten: scrollWidth == clientWidth.

Weil der Umschalter ohnehin umgebaut wurde, kommt die vierte Ansicht gleich
mit: eine Zeile je Projekt statt einer Karte. Auf dem Telefon ist das der
eigentliche Gewinn — eine Karte braucht rund 260px Hoehe und steht dort
zwangslaeufig allein in der Zeile, fuenf Projekte fuellen also zwei
Bildschirme. Als Liste passen sie auf einen, und weil alle Zeilen gleich
gebaut sind, liegen Name, Fortschritt und Status untereinander. Was nicht
mehr in die Breite passt, faellt der Reihe nach weg, statt sie zu sprengen.

Dazu die Kleinigkeiten, die auf dem Telefon dranhaengen:

- .field bekommt auf Beruehrungsgeraeten 16px. Darunter zoomt iOS Safari
  beim Hineintippen die Seite heran und kehrt nicht von selbst zurueck.
- .btn mindestens 44px hoch, der Schieberegler einen 24px-Griff.
- Der Popover klemmt seine Breite ans Fenster. Unter 280px lieferte die
  Rechnung eine negative linke Kante, das Menue stand halb ausserhalb.
- Das Tag-Formular der Auswahlleiste war 16px breiter als sein Popover und
  wurde von dessen overflow-hidden beschnitten – auf jeder Breite.
- Die Heatmap scrollt wieder seitlich, statt sich zu stauchen.
- Die Kennzahlen stehen zweispaltig; einspaltig fuellten die fuenf Kacheln
  den ganzen ersten Bildschirm, bevor das erste Projekt kam.
- viewportFit und die safe-area-Abstaende, damit im installierten Betrieb
  nichts unter Statusleiste und Home-Anzeige verschwindet.
- Die beiden Verweise in der Kopfleiste und "Neues Projekt" tragen ein
  aria-label. Unterhalb von sm sind sie reine Symbole und hiessen fuer einen
  Screenreader gar nichts; axe-core meldete das als link-name.

axe-core laeuft auf 375px ueber alle vier Ansichten, den Dialog und die
Projektseite jetzt ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:58:27 +02:00
florian.wildeandClaude Opus 5 86f1fbeb50 fix(scripts): Ausfuehrbarkeitsbit im Git setzen
CI / pruefen (push) Has been cancelled
Die Shell-Skripte lagen als 100644 im Index. Nach einem frischen Clone auf
dem Server scheiterte damit jedes "./scripts/setup.sh" und "./scripts/update.sh"
an fehlender Berechtigung; wer sich mit chmod +x behalf, hatte danach ein
schmutziges Arbeitsverzeichnis, und genau daran brach update.sh ab.

Unter Windows faellt das nicht auf, weil core.filemode dort auf false steht --
das Bit muss deshalb mit "git update-index --chmod=+x" gesetzt werden. Auch
docker/entrypoint.sh bekommt es; der Dockerfile-Aufruf chmod +x bleibt als
zweiter Riegel bestehen.

Ausserdem sagt update.sh jetzt, welche Dateien geaendert sind, statt nur zu
melden, dass etwas nicht sauber ist. Wer die Meldung liest, soll nicht erst
"git status" nachschlagen muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:13:56 +02:00
florian.wildeandClaude Opus 5 fc54c73791 fix(docker): die Datenbank veroeffentlicht ueberhaupt keinen Host-Port mehr
CI / pruefen (push) Has been cancelled
Der letzte Anlauf hat das Problem nicht geloest. Die Freigabe stand weiter in
der Basisdatei, nur mit anderem Vorgabewert -- und Vorgabewerte greifen nicht,
wo die Variable schon gesetzt ist: in jeder .env, die aus der bisherigen
.env.example entstanden ist, steht POSTGRES_PORT="5432". Postgres band also
unveraendert auf 0.0.0.0 und kollidierte mit anderen Stacks auf demselben
Host; der Container kam gar nicht erst hoch. Das Overlay half nicht, weil es
erst greift, wenn man es dazunimmt.

Jetzt hat der db-Dienst keinen ports-Block mehr. Gebraucht wird er nur, wenn
die Anwendung ausserhalb des Containers laeuft, und dafuer gibt es
docker-compose.dev.yml (npm run docker:db). Damit ist der sichere Fall die
Vorgabe und der Sonderfall die Ausnahme, nicht umgekehrt.

Der App-Port bindet aus demselben Grund auf 127.0.0.1: Docker traegt seine
Freigaben direkt in iptables ein und umgeht dabei ufw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:46:24 +02:00
florian.wildeandClaude Opus 5 d311fe48c0 fix(docker): keine Ports auf 0.0.0.0 und ein Overlay ohne Freigaben
CI / pruefen (push) Has been cancelled
Der Datenbankport wurde in beiden Betriebsarten auf 0.0.0.0 gelegt, obwohl
ihn nur die Entwicklung braucht: im Container spricht die Anwendung "db:5432"
ueber das Compose-Netz an, und backup.sh geht ueber docker exec. Auf einem
Server lauschte Postgres damit im Netz, geschuetzt allein durch das Passwort
aus der .env. Die Anleitung riet zwar zu 127.0.0.1, aber als Handgriff statt
als Voreinstellung -- die falsche Richtung. Datenbank und Adminer binden
jetzt von Haus aus auf 127.0.0.1.

Liegen mehrere Stacks auf einem Host und regelt ein gemeinsamer Reverse Proxy
den Zugang, braucht Nebula gar keinen Host-Port. docker-compose.server.yml
nimmt beide Freigaben zurueck und haengt die Anwendung in ein geteiltes Netz.
Das braucht !reset: Compose fuehrt "ports" beim Zusammenfuehren anhaengend
statt ersetzend zusammen, eine leere Liste im Override wuerde also nichts
loeschen.

update.sh las die Adresse aus APP_PORT und haette in diesem Modus einen Port
gemeldet, den es nicht gibt. Es fragt jetzt Docker und sagt sonst, dass nur
das interne Netz erreichbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:51 +02:00
florian.wildeandClaude Opus 5 6808d4d34a fix(scripts): Einrichtung und Update ohne Node
CI / pruefen (push) Has been cancelled
Ein reiner Docker-Host hat kein Node installiert. Die Anleitung verlangte
trotzdem "node scripts/setup.mjs", und update.sh las die Versionsnummer mit
"node -p" - beides waere dort gescheitert.

scripts/setup.sh macht dasselbe wie die .mjs-Fassung in reinem POSIX-Shell:
.env aus der Vorlage anlegen, ein APP_SECRET wuerfeln (openssl, sonst
/dev/urandom) und die Rechte auf 600 setzen. Ersetzt wird zeilenweise statt
mit einem sed-Ausdruck, weil base64 Schraegstriche enthaelt. update.sh liest
die Version jetzt selbst aus der package.json.

Dabei fielen zwei Fehler auf, die mein eigener Rat verursacht hat. Die
Anleitung empfiehlt APP_PORT="127.0.0.1:3000", damit die Anwendung nur hinter
dem Reverse Proxy erreichbar ist - die Gesundheitspruefung baute daraus aber
"http://127.0.0.1:127.0.0.1:3000". Und curl ist auf einem Docker-Host nicht
garantiert. Gefragt wird deshalb jetzt der Container selbst ueber seinen
HEALTHCHECK; curl liefert nur noch die Antwort zum Anzeigen, wenn es da ist.

Am Ende nannte das Skript den Sicherungspfad mit Anfuehrungszeichen - so
liess er sich nicht kopieren.

Nachgewiesen: dreimal gegen die laufende Instanz durchgelaufen, jedes Mal
mit Sicherung, Bau, Neustart und gesunder Antwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:26:46 +02:00
29 changed files with 813 additions and 145 deletions
+22 -6
View File
@@ -23,11 +23,13 @@ APP_URL="http://localhost:3000"
# Port innerhalb des Containers bzw. im Standalone-Betrieb # Port innerhalb des Containers bzw. im Standalone-Betrieb
PORT="3000" PORT="3000"
# Host-Port-Mapping fuer den App-Container (--profile full) # Host-Port-Mapping fuer den App-Container (--profile full). Nur auf dem
APP_PORT="3000" # Loopback – nach draussen geht es ueber einen Reverse Proxy. Soll die
# Instanz im LAN erreichbar sein, hier "3000" eintragen.
APP_PORT="127.0.0.1:3000"
# ── Datenbank ──────────────────────────────────────────────── # ── Datenbank ────────────────────────────────────────────────
# Standalone-Betrieb: App laeuft lokal, Postgres via "docker compose up -d db" # Standalone-Betrieb: App laeuft lokal, Postgres via "npm run docker:db"
# oder als nativ installierter Dienst. # oder als nativ installierter Dienst.
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public" DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
@@ -38,10 +40,24 @@ DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
POSTGRES_USER="nebula" POSTGRES_USER="nebula"
POSTGRES_PASSWORD="nebula" POSTGRES_PASSWORD="nebula"
POSTGRES_DB="nebula" POSTGRES_DB="nebula"
# Host-Port, unter dem die Datenbank erreichbar ist # Host-Port der Datenbank. Wirkt NUR zusammen mit docker-compose.dev.yml
POSTGRES_PORT="5432" # (npm run docker:db) – die Basisdatei veroeffentlicht keinen DB-Port, weil
# die Anwendung im Container "db:5432" anspricht und nichts davon nach
# aussen muss. Auf einem Server bleibt dieser Wert also folgenlos.
POSTGRES_PORT="127.0.0.1:5432"
# Host-Port fuer Adminer (--profile tools) # Host-Port fuer Adminer (--profile tools)
ADMINER_PORT="8080" ADMINER_PORT="127.0.0.1:8080"
# ── Betrieb ohne veroeffentlichte Ports ──────────────────────
# Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
# Reverse Proxy den Zugang, veroeffentlicht Nebula gar keinen Port. Dann diese
# beiden Zeilen aktivieren – danach genuegt ueberall "docker compose up -d":
# COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
# COMPOSE_PROFILES="full"
#
# Name des geteilten Netzes und der Alias, unter dem der Proxy Nebula findet.
# PROXY_NETWORK="proxy"
# PROXY_ALIAS="nebula"
# ── Sicherheit ─────────────────────────────────────────────── # ── Sicherheit ───────────────────────────────────────────────
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM). # Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
+2 -1
View File
@@ -9,7 +9,8 @@ Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
## Womit man arbeitet ## Womit man arbeitet
```bash ```bash
docker compose up -d db # Postgres, muss laufen npm run docker:db # Postgres, muss laufen (mit dem dev-Overlay,
# sonst hat die DB keinen Host-Port)
npm run dev # Entwicklung npm run dev # Entwicklung
npm test # Unit-Tests (Vitest, ohne DB) npm test # Unit-Tests (Vitest, ohne DB)
npx tsc --noEmit # Typprüfung npx tsc --noEmit # Typprüfung
+90 -20
View File
@@ -13,6 +13,7 @@ Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
| | | | | |
| --- | --- | | --- | --- |
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 | | Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
| Sonst | `git`, `curl`, eine POSIX-Shell — **kein Node, kein npm** |
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB | | Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank | | Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
| Domain | Eine (Unter-)Domain, die auf den Server zeigt | | Domain | Eine (Unter-)Domain, die auf den Server zeigt |
@@ -46,17 +47,22 @@ cd /opt/nebula
## 3 · Konfigurieren ## 3 · Konfigurieren
```bash ```bash
node scripts/setup.mjs # legt .env an und würfelt ein APP_SECRET ./scripts/setup.sh # legt .env an, würfelt ein APP_SECRET, setzt Rechte 600
``` ```
Ohne Node auf dem Server: `cp .env.example .env` und das Geheimnis von Hand Das Skript ist reines POSIX-Shell und braucht **kein Node** — auf einem
setzen — reinen Docker-Host ist keins installiert. Es nimmt `openssl`, falls
vorhanden, sonst `/dev/urandom`.
Ganz von Hand geht auch:
```bash ```bash
openssl rand -base64 48 cp .env.example .env
chmod 600 .env
openssl rand -base64 32 # den Wert als APP_SECRET eintragen
``` ```
Dann die `.env` öffnen und mindestens diese vier Werte anpassen: Dann die `.env` öffnen und mindestens diese drei Werte anpassen:
```env ```env
# Die Adresse, unter der du Nebula im Browser aufrufst. # Die Adresse, unter der du Nebula im Browser aufrufst.
@@ -68,11 +74,11 @@ APP_SECRET="…dein-langer-zufallswert…"
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung. # Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…" POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
# Nur lokal lauschen — nach außen geht es über den Reverse Proxy (Schritt 6).
APP_PORT=127.0.0.1:3000
``` ```
Die Ports müssen nicht angefasst werden: `APP_PORT` steht schon auf
`127.0.0.1:3000`, und die Datenbank veröffentlicht gar nichts.
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar. > **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys > Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
> bleiben unberührt. > bleiben unberührt.
@@ -80,19 +86,60 @@ APP_PORT=127.0.0.1:3000
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil > **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
> sie an die rpID gebunden sind. Passwörter funktionieren weiter. > sie an die rpID gebunden sind. Passwörter funktionieren weiter.
### Datenbankport schließen ### Welche Ports überhaupt hinausgehen
`docker-compose.yml` bildet Postgres standardmäßig auf `5432` ab, damit man Nach einer regulären Installation ist es **genau einer**:
in der Entwicklung von außen drankommt. Auf einem Server hat die Datenbank
nichts im Internet zu suchen:
```env | Container | Host-Port | Wann |
POSTGRES_PORT=127.0.0.1:5432 | --- | --- | --- |
| `nebula-app` | `APP_PORT`, Vorgabe `127.0.0.1:3000` | `--profile full` |
| `nebula-db` | — | **nie** |
| `nebula-adminer` | `ADMINER_PORT`, Vorgabe `127.0.0.1:8080` | nur `--profile tools` |
**Die Datenbank veröffentlicht nichts.** Im Vollbetrieb spricht die Anwendung
Postgres über das interne Docker-Netz als `db:5432` an — die `DATABASE_URL`
setzt Compose selbst. Auch `scripts/backup.sh` und `restore.sh` gehen über
`docker exec`, nicht über den Host. Ein Host-Port wäre nur eine zusätzliche
Angriffsfläche und würde mit anderen Postgres-Containern auf demselben Host
um den Port streiten.
Wer die Datenbank beim Entwickeln doch vom Host aus braucht — `npm run dev`
läuft ja außerhalb des Containers — nimmt `docker-compose.dev.yml` dazu
(`npm run docker:db`). Auf einem Server hat diese Datei nichts zu suchen.
Auch der Port der Anwendung liegt auf `127.0.0.1`: nach draußen geht es über
den Reverse Proxy aus Schritt 6.
### Variante: gar keinen Port veröffentlichen
Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
Reverse Proxy den Zugang, braucht Nebula überhaupt keinen Host-Port. Dafür
gibt es **`docker-compose.server.yml`**: es nimmt die letzte verbliebene
Freigabe zurück und hängt die Anwendung in ein geteiltes Netz.
```bash
docker network create proxy # falls der Proxy-Stack es nicht schon anlegt
``` ```
Noch besser: die `ports:`-Zeile des `db`-Dienstes ganz entfernen. Die ```env
Anwendung erreicht die Datenbank über das interne Docker-Netz, nicht über # .env
den Host. COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
COMPOSE_PROFILES="full"
PROXY_NETWORK="proxy" # so heißt das Netz
PROXY_ALIAS="nebula" # so findet der Proxy die Anwendung
```
Compose liest beides aus der `.env`. Danach genügt überall — auch in
`scripts/update.sh` — ein schlichtes `docker compose up -d`. Im Proxy zeigt
das Ziel dann nicht mehr auf `127.0.0.1:3000`, sondern auf **`nebula:3000`**;
Schritt 6 zeigt beide Schreibweisen.
> Ohne die Datei würde ein Override die Portlisten **zusammenhängen** statt
> sie zu ersetzen — Compose führt `ports` anfügend zusammen. Das `!reset`
> darin ist genau dafür da und braucht Compose 2.24 oder neuer.
>
> Das Trennzeichen in `COMPOSE_FILE` ist unter Linux der Doppelpunkt, unter
> Windows das Semikolon.
## 4 · Starten ## 4 · Starten
@@ -108,6 +155,13 @@ docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…} curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
``` ```
Ohne veröffentlichten Port geht die Frage an den Container statt an den Host
— das Abbild bringt einen `HEALTHCHECK` mit:
```bash
docker inspect --format '{{.State.Health.Status}}' nebula-app # healthy
```
## 5 · Ersten Zugang anlegen ## 5 · Ersten Zugang anlegen
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
@@ -145,6 +199,16 @@ sudo systemctl reload caddy
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
Let's Encrypt und leitet `http` auf `https` um. Let's Encrypt und leitet `http` auf `https` um.
Läuft der Proxy selbst als Container im geteilten Netz (die Variante ohne
veröffentlichte Ports), lautet das Ziel stattdessen `nebula:3000` —
Docker löst den Alias auf:
```caddyfile
nebula.deine-domain.de {
reverse_proxy nebula:3000
}
```
<details> <details>
<summary>Dasselbe mit nginx</summary> <summary>Dasselbe mit nginx</summary>
@@ -182,8 +246,13 @@ sudo ufw allow 80,443/tcp
sudo ufw enable sudo ufw enable
``` ```
Port 3000 und 5432 bleiben **zu**. Beide sind nach Schritt 3 ohnehin nur an Port 3000 bleibt **zu**, 5432 wird ohnehin nie veröffentlicht. Die Firewall
`127.0.0.1` gebunden; die Firewall ist der zweite Riegel. ist hier der zweite Riegel, nicht der erste.
> Docker schreibt seine Portfreigaben direkt in `iptables` und geht dabei an
> `ufw` **vorbei**. Eine Freigabe auf `0.0.0.0` steht also im Netz, auch wenn
> `ufw` sie zu melden scheint. Genau deshalb wird hier an `127.0.0.1`
> gebunden, statt sich auf die Firewall zu verlassen.
## 8 · Sicherungen automatisieren ## 8 · Sicherungen automatisieren
@@ -227,11 +296,12 @@ Was es tut:
| Schritt | | | Schritt | |
| --- | --- | | --- | --- |
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? | | **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
| | *(alles ohne Node — das Skript liest die Version selbst aus der `package.json`)* |
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach | | **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung | | **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen | | **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint | | **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
| **6 Prüfen** | Bis zu 60 Sekunden auf `/api/health` warten | | **6 Prüfen** | Bis zu zwei Minuten warten, bis der Container sich gesund meldet |
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
nennt den Weg zurück. nennt den Weg zurück.
+19 -1
View File
@@ -115,7 +115,8 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**. - **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten - **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie (Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
@@ -165,6 +166,23 @@ Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Lei
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte, Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch. die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Liste
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
Gedacht ist die Ansicht für zwei Fälle:
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
### Kanban ### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
+6 -6
View File
@@ -52,7 +52,7 @@ node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRE
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container: Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
```bash ```bash
docker compose up -d db # Postgres 17 auf localhost:5432 npm run docker:db # Postgres 17 auf 127.0.0.1:5432
npm install npm install
npm run db:migrate # Schema anlegen npm run db:migrate # Schema anlegen
npm run db:seed # optional: drei Beispielprojekte npm run db:seed # optional: drei Beispielprojekte
@@ -120,10 +120,10 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** | | `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` | | `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
| `PORT` | Port der Anwendung | `3000` | | `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `3000` | | `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
| `POSTGRES_PORT` | Host-Port der Datenbank | `5432` | | `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `8080` | | `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` | | `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
@@ -307,7 +307,7 @@ nebula/
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) | | `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) | | `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander | | `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten | | `npm run docker:db` | Nur die Datenbank starten, mit Host-Port für `npm run dev` |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen | | `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` | | `npm run docker:tools` | Adminer auf `http://localhost:8080` |
@@ -416,7 +416,7 @@ Grenzen, die du kennen solltest:
| Symptom | Ursache / Lösung | | Symptom | Ursache / Lösung |
| --- | --- | | --- | --- |
| `Can't reach database server` | Läuft `docker compose up -d db`? Stimmt `DATABASE_URL`? | | `Can't reach database server` | Läuft `npm run docker:db`? Ein blosses `docker compose up -d db` startet die DB **ohne Host-Port** — dann ist sie von `npm run dev` aus nicht erreichbar. Stimmt `DATABASE_URL`? |
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` | | `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen | | Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" | | Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
+23
View File
@@ -0,0 +1,23 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer die Entwicklung: macht die Datenbank auf dem Host erreichbar.
#
# Gebraucht wird das nur, wenn die Anwendung ausserhalb des Containers
# laeuft – "npm run dev" verbindet sich dann ueber localhost:5432. Im
# Vollbetrieb spricht die Anwendung "db:5432" im Docker-Netz an und braucht
# diese Datei nicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# Kuerzer: npm run docker:db
#
# Gebunden wird auf 127.0.0.1, nicht auf 0.0.0.0. Docker traegt Freigaben
# direkt in iptables ein und umgeht dabei ufw – eine offene Bindung stuende
# also auch bei aktiver Firewall im Netz.
# ─────────────────────────────────────────────────────────────
services:
db:
ports:
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
+46
View File
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer den Serverbetrieb: kein einziger Port geht zum Host.
#
# Gedacht fuer einen Docker-Host, auf dem mehrere Stacks liegen und ein
# gemeinsamer Reverse Proxy die einzige Tuer nach draussen ist. Die
# Datenbank veroeffentlicht ohnehin nichts; diese Datei nimmt zusaetzlich
# die Freigabe der Anwendung zurueck und haengt sie in ein geteiltes Netz,
# ueber das der Proxy sie erreicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.server.yml \
# --profile full up -d --build
#
# Bequemer ist, das dauerhaft in die .env zu schreiben:
# COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
# COMPOSE_PROFILES=full
# Danach genuegt ueberall "docker compose up -d" – auch in update.sh.
# (Trennzeichen: Doppelpunkt unter Linux, Semikolon unter Windows.)
#
# Das Netz muss es vorher geben (der Proxy-Stack legt es ueblicherweise an):
# docker network create proxy
#
# Im Proxy zeigt das Ziel dann auf http://nebula:3000 bzw. nebula-app:3000.
# ─────────────────────────────────────────────────────────────
services:
app:
# !reset loescht die Portfreigabe aus der Basisdatei. Ohne dieses Tag
# wuerden die Listen zusammengefuegt statt ersetzt – Compose behandelt
# "ports" beim Zusammenfuehren als anzuhaengende Liste. Braucht
# Compose 2.24 oder neuer.
ports: !reset []
networks:
# Das stackeigene Netz: darueber spricht die Anwendung mit der Datenbank.
default:
# Das geteilte Netz zum Reverse Proxy. Der Alias ist der Name, unter dem
# der Proxy Nebula anspricht – kurz und eindeutig, denn "app" hiessen in
# einem geteilten Netz schnell mehrere Container.
proxy:
aliases:
- ${PROXY_ALIAS:-nebula}
networks:
proxy:
external: true
name: ${PROXY_NETWORK:-proxy}
+21 -9
View File
@@ -1,14 +1,20 @@
name: nebula name: nebula
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Zwei Betriebsarten aus einer Datei: # Diese Datei veroeffentlicht nur einen einzigen Port, und den auf dem
# Loopback: den der Anwendung. Die Datenbank bleibt im Docker-Netz.
# #
# 1) Standalone-Entwicklung – nur die Datenbank im Container: # 1) Alles im Container (Server wie Entwicklung):
# docker compose up -d db # docker compose --profile full up -d --build
#
# 2) Standalone-Entwicklung – Datenbank im Container, App auf dem Host.
# Dafuer muss Postgres auf dem Host erreichbar sein, also mit dem
# Entwicklungs-Overlay (oder kurz: npm run docker:db):
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev # npm run dev
# #
# 2) Alles im Container: # 3) Server ohne jede Portfreigabe, hinter einem Reverse Proxy im
# docker compose --profile full up -d --build # gemeinsamen Docker-Netz: siehe docker-compose.server.yml
# #
# Optionales DB-Webinterface: # Optionales DB-Webinterface:
# docker compose --profile tools up -d adminer → http://localhost:8080 # docker compose --profile tools up -d adminer → http://localhost:8080
@@ -25,8 +31,10 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-nebula} POSTGRES_DB: ${POSTGRES_DB:-nebula}
# Deterministische Sortierung unabhaengig vom Host-Locale # Deterministische Sortierung unabhaengig vom Host-Locale
POSTGRES_INITDB_ARGS: "--encoding=UTF8" POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports: # Absichtlich keine Portfreigabe: die Anwendung erreicht die Datenbank im
- "${POSTGRES_PORT:-5432}:5432" # Container ueber "db:5432", und backup.sh geht ueber "docker exec". Nach
# aussen muss hier nichts. Wer die Datenbank vom Host aus braucht – fuer
# "npm run dev" oder psql – nimmt docker-compose.dev.yml dazu.
volumes: volumes:
- nebula-db-data:/var/lib/postgresql/data - nebula-db-data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -73,7 +81,11 @@ services:
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
ports: ports:
- "${APP_PORT:-3000}:3000" # Nur auf dem Loopback: nach draussen kommt man ueber den Reverse Proxy.
# Docker schreibt seine Freigaben an ufw vorbei direkt in iptables – eine
# Bindung auf 0.0.0.0 stuende also im Netz, auch bei aktiver Firewall.
# Ganz ohne Host-Port: docker-compose.server.yml.
- "${APP_PORT:-127.0.0.1:3000}:3000"
adminer: adminer:
profiles: ["tools"] profiles: ["tools"]
@@ -87,7 +99,7 @@ services:
ADMINER_DEFAULT_SERVER: db ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula ADMINER_DESIGN: dracula
ports: ports:
- "${ADMINER_PORT:-8080}:8080" - "${ADMINER_PORT:-127.0.0.1:8080}:8080"
volumes: volumes:
nebula-db-data: nebula-db-data:
Regular → Executable
View File
+25
View File
@@ -106,6 +106,31 @@ test("Volltextsuche findet Notizen", async ({ page }) => {
await expect(page.getByText("Treffer in Notizen")).toBeVisible(); await expect(page.getByText("Treffer in Notizen")).toBeVisible();
}); });
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Liste" }).click();
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
// Seite liess sich seitwaerts schieben.
const masse = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
const knopf = page.getByRole("button", { name: "Neues Projekt" });
await expect(knopf).toBeVisible();
const kasten = await knopf.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+1 -1
View File
@@ -16,7 +16,7 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"docker:db": "docker compose up -d db", "docker:db": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db",
"docker:db:down": "docker compose stop db", "docker:db:down": "docker compose stop db",
"docker:up": "docker compose --profile full up -d --build", "docker:up": "docker compose --profile full up -d --build",
"docker:down": "docker compose --profile full down", "docker:down": "docker compose --profile full down",
Regular → Executable
View File
Regular → Executable
View File
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Legt die .env an und wuerfelt ein APP_SECRET.
#
# Dasselbe wie scripts/setup.mjs, aber ohne Node: auf einem reinen
# Docker-Host ist keins installiert, und nur zum Erzeugen einer Textdatei
# eines zu verlangen waere albern. Braucht nichts ausser einer POSIX-Shell.
#
# Aufruf: ./scripts/setup.sh
# ./scripts/setup.sh --force (vorhandene .env ueberschreiben)
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
env_datei="$root/.env"
vorlage="$root/.env.example"
force=0
[ "${1:-}" = "--force" ] && force=1
if [ -t 1 ]; then
gruen=$'\033[32m'; gelb=$'\033[33m'; cyan=$'\033[36m'; dim=$'\033[2m'; aus=$'\033[0m'
else
gruen=""; gelb=""; cyan=""; dim=""; aus=""
fi
[ -f "$vorlage" ] || { echo "${gelb}.env.example nicht gefunden – abgebrochen.${aus}" >&2; exit 1; }
# ── Zufall ───────────────────────────────────────────────────
# openssl ist auf den meisten Systemen da, aber nicht auf allen. /dev/urandom
# gibt es ueberall, wo diese Shell laeuft – und base64 ebenso.
zufall() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 32
else
head -c 32 /dev/urandom | base64
fi | tr -d '\n'
}
if [ -f "$env_datei" ] && [ "$force" -eq 0 ]; then
echo "${gelb}·${aus} .env existiert bereits – sie wird nicht ueberschrieben."
echo " ${dim}Mit --force neu anlegen (die alte wird gesichert).${aus}"
else
if [ -f "$env_datei" ]; then
sicherung="$env_datei.$(date +%Y%m%d%H%M%S).bak"
cp "$env_datei" "$sicherung"
echo "${gelb}·${aus} Alte .env gesichert: $(basename "$sicherung")"
fi
secret="$(zufall)"
# Das Geheimnis kann / und + enthalten; als sed-Ersetzung waeren das
# Sonderzeichen. Deshalb zeilenweise ersetzen statt mit einem Ausdruck.
: > "$env_datei"
while IFS= read -r zeile || [ -n "$zeile" ]; do
case "$zeile" in
APP_SECRET=*) printf 'APP_SECRET="%s"\n' "$secret" >> "$env_datei" ;;
*) printf '%s\n' "$zeile" >> "$env_datei" ;;
esac
done < "$vorlage"
chmod 600 "$env_datei"
echo "${gruen}✓${aus} .env angelegt, APP_SECRET zufaellig erzeugt, Rechte auf 600."
fi
echo
echo "${cyan}Noch anzupassen${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " APP_URL die Adresse, unter der du Nebula aufrufst"
echo " ${dim}bestimmt die WebAuthn-rpID – passt sie nicht, scheitern Passkeys${aus}"
echo " POSTGRES_PASSWORD die Voreinstellung ist fuer Entwicklung gedacht"
echo " APP_PORT auf einem Server: 127.0.0.1:3000 hinter den Reverse Proxy"
echo
echo "${cyan}Danach${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " docker compose --profile full up -d --build"
echo " ${dim}Der Container spielt die Migrationen beim Start selbst ein.${aus}"
echo
echo " Ausfuehrlich: DEPLOYMENT.md"
echo
Regular → Executable
+52 -21
View File
@@ -40,6 +40,13 @@ else
rot=""; gruen=""; gelb=""; fett=""; aus="" rot=""; gruen=""; gelb=""; fett=""; aus=""
fi fi
# Die Version aus der package.json lesen – ohne Node. Auf einem reinen
# Docker-Host ist keins installiert, und dieses Skript soll dort laufen.
paket_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/package.json" \
| head -n 1
}
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; } schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; } ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; } warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
@@ -50,7 +57,7 @@ schritt "Voraussetzungen"
command -v docker >/dev/null || abbruch "docker nicht gefunden." command -v docker >/dev/null || abbruch "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden." docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst 'node scripts/setup.mjs' ausfuehren." [ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst './scripts/setup.sh' ausfuehren."
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root." [ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
ok "docker, compose und .env vorhanden" ok "docker, compose und .env vorhanden"
@@ -61,15 +68,22 @@ fi
ok "APP_SECRET gesetzt" ok "APP_SECRET gesetzt"
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")" vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
version_vorher="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")" version_vorher="$(paket_version)"
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher" printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
# ── Neuen Stand holen ──────────────────────────────────────── # ── Neuen Stand holen ────────────────────────────────────────
if [ "$pull" -eq 1 ]; then if [ "$pull" -eq 1 ]; then
schritt "Neuen Stand holen" schritt "Neuen Stand holen"
if [ -n "$(git status --porcelain)" ]; then schmutz="$(git status --porcelain)"
abbruch "Das Arbeitsverzeichnis ist nicht sauber. Bitte erst committen oder verwerfen." if [ -n "$schmutz" ]; then
# Nicht nur meckern, sondern zeigen, worum es geht – sonst muss man
# raten, was man da eigentlich verwerfen soll.
printf '\n%sGeaendert gegenueber dem Stand im Git:%s\n' "$fett" "$aus"
printf '%s\n' "$schmutz" | sed 's/^/ /'
printf '\n Verwerfen: git checkout -- .\n'
printf ' Behalten: git stash\n'
abbruch "Das Arbeitsverzeichnis ist nicht sauber."
fi fi
git fetch --all --tags --quiet git fetch --all --tags --quiet
@@ -83,7 +97,7 @@ if [ "$pull" -eq 1 ]; then
fi fi
nachher="$(git rev-parse --short HEAD)" nachher="$(git rev-parse --short HEAD)"
version_nachher="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")" version_nachher="$(paket_version)"
if [ "$vorher" = "$nachher" ]; then if [ "$vorher" = "$nachher" ]; then
warnung "Kein neuer Stand – es wird trotzdem neu gebaut." warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
@@ -111,7 +125,9 @@ docker compose up -d db >/dev/null
sicherung="" sicherung=""
if [ -x "$root/scripts/backup.sh" ]; then if [ -x "$root/scripts/backup.sh" ]; then
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe" ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ ]*backups/[^ ]*\.sql[^ ]*' | tail -n 1 || true)" # backup.sh setzt den Pfad in Anfuehrungszeichen – die gehoeren nicht in
# den Namen, sonst laesst sich der Vorschlag am Ende nicht kopieren.
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ "]*backups/[^ "]*\.sql[^ "]*' | tail -n 1 || true)"
ok "Gesichert${sicherung:+: $sicherung}" ok "Gesichert${sicherung:+: $sicherung}"
else else
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren." warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
@@ -132,16 +148,17 @@ ok "Container gestartet"
# ── Gesundheitspruefung ────────────────────────────────────── # ── Gesundheitspruefung ──────────────────────────────────────
schritt "Gesundheitspruefung" schritt "Gesundheitspruefung"
port="$(sed -n 's/^[[:space:]]*APP_PORT[[:space:]]*=[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' "$root/.env" | tail -n 1)" # Gefragt wird der Container selbst, nicht der Host. Das Abbild bringt einen
port="${port:-3000}" # HEALTHCHECK mit, und so ist es einerlei, ob APP_PORT nur eine Nummer ist
# oder wie auf einem Server "127.0.0.1:3000" – und curl braucht es auch nicht.
gesund=0 gesund=0
for versuch in $(seq 1 30); do for _ in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1; then zustand="$(docker inspect --format '{{.State.Health.Status}}' nebula-app 2>/dev/null || echo "weg")"
gesund=1 case "$zustand" in
break healthy) gesund=1; break ;;
fi unhealthy) break ;;
sleep 2 esac
sleep 3
done done
if [ "$gesund" -ne 1 ]; then if [ "$gesund" -ne 1 ]; then
@@ -150,14 +167,28 @@ if [ "$gesund" -ne 1 ]; then
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus" printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher" printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung" [ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
abbruch "Die Anwendung antwortet nach 60 Sekunden nicht gesund." abbruch "Der Container meldet sich nach zwei Minuten nicht gesund (Zustand: ${zustand})."
fi
ok "Container meldet sich gesund"
# Die Anzeige der Adresse. Nicht die .env raten, sondern Docker fragen: mit
# docker-compose.server.yml veroeffentlicht der Container gar keinen Port und
# haengt nur im Netz des Reverse Proxy. Dann gibt "port" nichts zurueck.
adresse="$(docker compose --profile full port app 3000 2>/dev/null | tail -n 1 || true)"
if [ -n "$adresse" ]; then
port="${adresse##*:}"
if command -v curl >/dev/null 2>&1; then
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health" 2>/dev/null || true)"
[ -n "$antwort_json" ] && ok "Antwort: $antwort_json"
fi
wo="$adresse"
else
wo="im Docker-Netz (kein Host-Port veroeffentlicht)"
fi fi
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health")"
ok "Gesund: $antwort_json"
# ── Fertig ─────────────────────────────────────────────────── # ── Fertig ───────────────────────────────────────────────────
version_jetzt="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")" version_jetzt="$(paket_version)"
printf '\n%s%s✓ Nebula v%s laeuft auf Port %s%s\n' "$gruen" "$fett" "$version_jetzt" "$port" "$aus" printf '\n%s%s✓ Nebula v%s laeuft auf %s%s\n' "$gruen" "$fett" "$version_jetzt" "$wo" "$aus"
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung" [ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n' printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
+46
View File
@@ -277,6 +277,21 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
/**
* Auf Zeigergeraeten reichen die 36px, die sich aus Schriftgroesse und
* Innenabstand ergeben. Ein Finger trifft das nicht zuverlaessig – dort
* gilt die uebliche Untergrenze von 44px.
*
* Ueber "pointer: coarse" statt einer Breitenabfrage: entscheidend ist das
* Eingabegeraet, nicht die Fenstergroesse. Ein Tablet im Querformat ist
* breit und wird trotzdem mit dem Finger bedient.
*/
@media (pointer: coarse) {
.btn {
min-height: 2.75rem;
}
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -335,6 +350,19 @@
box-shadow 0.2s ease; box-shadow 0.2s ease;
} }
/**
* Unter 16px zoomt iOS Safari beim Hineintippen die ganze Seite heran und
* kehrt danach nicht von selbst zurueck – aus einem Formular wird so eine
* Schiebepartie. Deshalb auf Beruehrungsgeraeten volle 16px, unabhaengig
* von der Fensterbreite.
*/
@media (pointer: coarse) {
.field {
font-size: 1rem;
padding: 0.65rem 0.85rem;
}
}
.field::placeholder { .field::placeholder {
color: rgba(148, 163, 184, 0.6); color: rgba(148, 163, 184, 0.6);
} }
@@ -416,4 +444,22 @@
.range::-moz-range-track { .range::-moz-range-track {
background: transparent; background: transparent;
} }
/* Ein 16px-Griff ist mit dem Finger kaum zu fassen. Die Spur bleibt
schmal, damit der Regler nicht ploetzlich klobig wirkt. */
@media (pointer: coarse) {
.range {
height: 10px;
}
.range::-webkit-slider-thumb {
height: 24px;
width: 24px;
}
.range::-moz-range-thumb {
height: 22px;
width: 22px;
}
}
} }
+14
View File
@@ -38,6 +38,13 @@
Consolas, "DejaVu Sans Mono", monospace; Consolas, "DejaVu Sans Mono", monospace;
--radius-card: 1.25rem; --radius-card: 1.25rem;
/* Eine Stufe unterhalb von Tailwinds "sm" (40rem). Zwischen 320px und
640px liegt der gesamte Telefonbereich, und dort ist der Unterschied
zwischen "Beschriftung passt noch" und "passt nicht mehr" oft nur ein
Zentimeter breit. 22rem trennt die wirklich schmalen Geraete (iPhone SE
der ersten Reihe, 320px) vom Rest, der bei 360px und mehr anfaengt. */
--breakpoint-xs: 22rem;
} }
:root { :root {
@@ -63,7 +70,14 @@
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
/* Ein Notnagel gegen seitliches Scrollen, kein Ersatz fuer richtiges
Umbrechen: was hier abgeschnitten wird, ist auf dem Telefon einfach
nicht mehr erreichbar. Die Ursachen gehoeren in die Komponenten. */
overflow-x: hidden; overflow-x: hidden;
/* Mit viewportFit: "cover" reicht die Seite bis in die Ecken; ohne diese
Einrueckung stuende der Inhalt im Querformat unter der Kameraleiste. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
} }
::selection { ::selection {
+5
View File
@@ -42,6 +42,11 @@ export const viewport: Viewport = {
themeColor: "#05060f", themeColor: "#05060f",
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
// Die Seite reicht bis unter Statusleiste und Home-Anzeige; das ist noetig,
// damit der Hintergrund im installierten Betrieb nicht an einem weissen
// Balken endet. Was dadurch verdeckt wuerde, holen die env(safe-area-*)
// in globals.css wieder heraus.
viewportFit: "cover",
}; };
export default async function RootLayout({ export default async function RootLayout({
+5 -1
View File
@@ -52,13 +52,17 @@ export default async function DashboardPage() {
</p> </p>
</header> </header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> {/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
Kacheln dort den ganzen ersten Bildschirm, und die Projekte – der
eigentliche Inhalt – begannen erst nach dem zweiten Wischen. */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
<StatCard <StatCard
label="Projekte" label="Projekte"
value={active.length} value={active.length}
hint={`Ø Fortschritt ${averageProgress}%`} hint={`Ø Fortschritt ${averageProgress}%`}
icon={CircleDot} icon={CircleDot}
color="#a78bfa" color="#a78bfa"
className="col-span-2 lg:col-span-1"
/> />
<StatCard <StatCard
label="Ideen" label="Ideen"
+18 -4
View File
@@ -66,8 +66,13 @@ export function BulkBar({
} }
return ( return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4"> // Der Abstand nach unten beruecksichtigt die Home-Anzeige des iPhones;
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl"> // ohne das liegt die Leiste im installierten Betrieb darunter.
<div
className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-3"
style={{ bottom: "calc(1rem + env(safe-area-inset-bottom))" }}
>
<div className="popover pointer-events-auto flex max-w-full flex-wrap items-center justify-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200"> <span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt {ids.length} ausgewählt
</span> </span>
@@ -122,8 +127,17 @@ export function BulkBar({
<Tag className="h-3.5 w-3.5" /> <Tag className="h-3.5 w-3.5" />
Tags Tags
</button> </button>
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start"> {/* Die Breite gehoert an den Popover, nicht an sein Kind: der Popover
<div className="w-64 space-y-2 p-3"> setzt overflow-hidden, ein breiteres Kind wird schlicht
abgeschnitten – frueher um die Haelfte des Abbrechen-Knopfs. */}
<Popover
open={tagsOpen}
anchorRef={tagsRef}
onClose={() => setTagsOpen(false)}
align="start"
width={288}
>
<div className="space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags"> <label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt) Tags (kommagetrennt)
</label> </label>
+8 -2
View File
@@ -114,12 +114,18 @@ export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
</p> </p>
</div> </div>
<div className="overflow-x-auto"> <div className="-mx-1 overflow-x-auto px-1">
{/*
Kein max-w-full: zusammen mit der festen Hoehe staucht das die
Wochen auf schmalen Anzeigen zu einem unlesbaren Streifen, statt
den Elter seitlich scrollen zu lassen. Die Kalenderfelder haben
eine feste Groesse – die Karte gehoert geschoben, nicht gequetscht.
*/}
<svg <svg
viewBox={`0 0 ${breite + 22} ${hoehe}`} viewBox={`0 0 ${breite + 22} ${hoehe}`}
width={breite + 22} width={breite + 22}
height={hoehe} height={hoehe}
className="max-w-full" className="block shrink-0"
role="img" role="img"
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`} aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
> >
+5 -1
View File
@@ -253,7 +253,11 @@ function Column({
// Eindeutiger Anker fuer Tests und Automatisierung. // Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status} data-column={status}
className={cn( className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors", // Schmaler auf dem Telefon: bei 17rem stand die zweite Spalte fast
// vollstaendig ausserhalb des Bildschirms, und ohne einen sichtbaren
// Rest der naechsten Spalte sieht man dem Brett nicht an, dass es
// seitlich weitergeht.
"flex w-[14rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors sm:w-[17rem]",
isOver && "border-violet-400/40 bg-violet-500/6", isOver && "border-violet-400/40 bg-violet-500/6",
)} )}
> >
+66 -48
View File
@@ -7,6 +7,7 @@ import {
Database, Database,
Download, Download,
LayoutGrid, LayoutGrid,
List,
ListFilter, ListFilter,
NotebookPen, NotebookPen,
Plus, Plus,
@@ -21,6 +22,7 @@ import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard"; import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { ProjectList } from "@/components/ProjectList";
import { Popover } from "@/components/ui/Popover"; 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";
@@ -53,9 +55,17 @@ function highlight(snippet: string): React.ReactNode[] {
} }
type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban"; type ViewMode = "grid" | "list" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"]; const VIEWS: ViewMode[] = ["grid", "list", "grouped", "kanban"];
/** Reihenfolge der Umschalter – zugleich die Beschriftung der Knoepfe. */
const VIEW_META: Record<ViewMode, { label: string; title: string; icon: typeof LayoutGrid }> = {
grid: { label: "Raster", title: "Rasteransicht", icon: LayoutGrid },
list: { label: "Liste", title: "Kompakte Liste", icon: List },
grouped: { label: "Gruppiert", title: "Nach Status gruppiert", icon: Rows3 },
kanban: { label: "Brett", title: "Kanban – Status per Ziehen aendern", icon: Columns3 },
};
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */ /** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board"; const ANSICHT_SCHLUESSEL = "nebula.board";
@@ -255,10 +265,14 @@ export function ProjectBoard({
return ( return (
<section className="space-y-5"> <section className="space-y-5">
{/* Steuerleiste */} {/* Steuerleiste.
<div className="glass flex flex-col gap-4 p-4"> Auf dem Telefon steht die Suche allein in der ersten Zeile und die
<div className="flex flex-wrap items-center gap-3"> Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht
<div className="relative min-w-[220px] flex-1"> umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem
Bild geschoben und die ganze Seite seitlich scrollbar. */}
<div className="glass flex flex-col gap-3 p-3 sm:gap-4 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:min-w-[220px] sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input <input
className="field pl-9" className="field pl-9"
@@ -282,9 +296,12 @@ export function ProjectBoard({
<label className="sr-only" htmlFor="sort"> <label className="sr-only" htmlFor="sort">
Sortierung Sortierung
</label> </label>
{/* min-w-0 ist hier entscheidend: ohne das weigert sich ein
Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und
drueckt stattdessen die Nachbarn hinaus. */}
<select <select
id="sort" id="sort"
className="field w-auto min-w-[11rem]" className="field w-auto min-w-0 flex-1 truncate sm:flex-none sm:min-w-[11rem]"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier // Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
@@ -303,50 +320,11 @@ export function ProjectBoard({
))} ))}
</select> </select>
<div className="flex overflow-hidden rounded-xl border border-white/9">
<button
type="button"
onClick={() => setView("grid")}
className={cn(
"p-2 transition-colors",
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Rasteransicht"
title="Rasteransicht"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("grouped")}
className={cn(
"p-2 transition-colors",
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Nach Status gruppiert"
title="Nach Status gruppiert"
>
<Rows3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</button>
</div>
<button <button
ref={dataButtonRef} ref={dataButtonRef}
type="button" type="button"
onClick={() => setDataMenuOpen((value) => !value)} onClick={() => setDataMenuOpen((value) => !value)}
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white" className="shrink-0 rounded-xl border border-white/9 p-2.5 text-slate-500 transition-colors hover:text-white"
aria-label="Daten exportieren oder importieren" aria-label="Daten exportieren oder importieren"
title="Export und Import" title="Export und Import"
aria-haspopup="menu" aria-haspopup="menu"
@@ -355,13 +333,51 @@ export function ProjectBoard({
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
</button> </button>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}> <button
type="button"
className="btn btn-primary shrink-0"
onClick={() => setDialogOpen(true)}
// Unterhalb von sm bleibt nur das Pluszeichen stehen. Ohne
// eigene Beschriftung liest ein Screenreader dann gar nichts vor.
aria-label="Neues Projekt"
>
<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>
</button> </button>
</div> </div>
</div> </div>
{/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite,
damit die vier Knoepfe gross genug zum Treffen bleiben. */}
<div
className="flex overflow-hidden rounded-xl border border-white/9 sm:w-auto sm:self-start"
role="group"
aria-label="Ansicht"
>
{VIEWS.map((mode) => {
const { label, title, icon: Icon } = VIEW_META[mode];
const active = view === mode;
return (
<button
key={mode}
type="button"
onClick={() => setView(mode)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors sm:flex-none",
active ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-pressed={active}
title={title}
>
<Icon className="h-4 w-4 shrink-0" />
{/* Nur auf den schmalsten Anzeigen bleiben die Symbole allein –
dort waeren vier Beschriftungen enger als die Fingerkuppe. */}
<span className="hidden xs:inline">{label}</span>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500"> <span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
<ListFilter className="h-3.5 w-3.5" /> <ListFilter className="h-3.5 w-3.5" />
@@ -471,6 +487,8 @@ export function ProjectBoard({
selection={selection} selection={selection}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
/> />
) : view === "list" ? (
<ProjectList projects={visible} selection={selection} onToggleSelect={toggleSelect} />
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => ( {visible.map((project, index) => (
+9 -3
View File
@@ -74,7 +74,7 @@ export function ProjectHeader({
return ( return (
<header <header
className="glass relative overflow-hidden p-6" className="glass relative overflow-hidden p-4 sm:p-6"
style={ style={
{ {
"--accent-from": accent.from, "--accent-from": accent.from,
@@ -168,9 +168,13 @@ export function ProjectHeader({
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-end gap-3"> {/* Auf dem Telefon steht dieser Block unter dem Titel und faengt
links an; erst ab sm rueckt er nach rechts neben ihn. Vorher
standen vier Knoepfe in einer Zeile, die nicht umbrechen durfte –
"Loeschen" lag damit ausserhalb des Bildschirms. */}
<div className="flex flex-wrap items-center gap-2 sm:flex-col sm:items-end sm:gap-3">
<StatusSelect projectId={project.id} status={project.status} /> <StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<button <button
ref={exportButtonRef} ref={exportButtonRef}
type="button" type="button"
@@ -179,6 +183,7 @@ export function ProjectHeader({
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={exportOpen} aria-expanded={exportOpen}
title="Exportieren" title="Exportieren"
aria-label="Exportieren"
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</button> </button>
@@ -187,6 +192,7 @@ export function ProjectHeader({
className="btn btn-ghost px-2.5 py-1.5 text-xs" className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTemplateOpen(true)} onClick={() => setTemplateOpen(true)}
title="Als Vorlage sichern" title="Als Vorlage sichern"
aria-label="Als Vorlage sichern"
> >
<BookmarkPlus className="h-3.5 w-3.5" /> <BookmarkPlus className="h-3.5 w-3.5" />
</button> </button>
+204
View File
@@ -0,0 +1,204 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
} from "lucide-react";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf, statusOf } from "@/lib/status";
import { cn, formatRelative } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
/**
* Listenansicht des Boards.
*
* Warum zusaetzlich zum Raster: eine Karte braucht rund 260px Hoehe, auf dem
* Telefon steht sie zwangslaeufig allein in der Zeile. Fuenf Projekte fuellen
* damit zwei Bildschirme, bevor man ueberhaupt etwas vergleichen kann. Eine
* Zeile kommt mit einem Viertel davon aus, und weil alle Zeilen gleich
* aufgebaut sind, liegen Name, Fortschritt und Status untereinander – genau
* das, was das Raster auf schmalen Anzeigen verliert.
*/
export function ProjectList({
projects,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
selection: Set<string>;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
return (
<ul className="glass fade-up divide-y divide-white/6 overflow-hidden">
{projects.map((project) => (
<ProjectRow
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</ul>
);
}
function ProjectRow({
project,
selected,
onToggleSelect,
}: {
project: ProjectListItem;
selected: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const meta = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.repositories[0]?.snapshot ?? null;
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
const next = !favorite;
setFavorite(next);
try {
const response = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: next }),
});
if (!response.ok) throw new Error("failed");
} catch {
setFavorite(!next);
}
}
return (
<li
className={cn("group relative transition-colors", selected && "bg-violet-500/8")}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
{/* Die ganze Zeile ist der Link; was darueber bedienbar sein soll,
bekommt weiter unten die Zeigerereignisse zurueck. */}
<Link
href={`/projects/${project.id}`}
className="absolute inset-0 z-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-400/60"
aria-label={`Projekt ${project.name} oeffnen`}
/>
<div className="pointer-events-none relative z-10 flex items-center gap-2.5 px-3 py-2.5 transition-colors group-hover:bg-white/4 sm:gap-3 sm:px-4 sm:py-3">
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-4 w-4 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
{/* Statusfarbe als Streifen: auf einen Blick erkennbar, ohne Platz
fuer eine Beschriftung zu verbrauchen. */}
<span
className="h-8 w-1 shrink-0 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-medium text-white group-hover:text-violet-200">
{project.name}
</h3>
{favorite ? (
<Star className="h-3 w-3 shrink-0 fill-amber-300 text-amber-300" />
) : null}
{syncError ? (
<AlertTriangle className="h-3 w-3 shrink-0 text-rose-300/80" aria-label="Sync-Fehler" />
) : null}
</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-white/8 sm:w-24">
<span
className="accent-bar block h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</span>
<span className="shrink-0 tabular-nums">{project.progress}%</span>
{/* Was nicht mehr in die Zeile passt, faellt der Reihe nach weg,
statt sie zu sprengen. */}
{project._count.tasks > 0 ? (
<span className="hidden shrink-0 items-center gap-1 xs:inline-flex">
<CheckSquare className="h-3 w-3" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
{snapshot?.defaultBranch ? (
<span className="hidden shrink-0 items-center gap-1 font-mono md:inline-flex">
<GitBranch className="h-3 w-3" />
{snapshot.defaultBranch}
</span>
) : null}
{snapshot?.lastCommitAt ? (
<span
className="hidden shrink-0 items-center gap-1 lg:inline-flex"
suppressHydrationWarning
>
<GitCommitHorizontal className="h-3 w-3" />
{formatRelative(snapshot.lastCommitAt)}
</span>
) : null}
<span
className="ml-auto hidden shrink-0 uppercase tracking-wider md:inline"
style={{ color: priority.color }}
title={`Prioritaet: ${priority.label}`}
>
{priority.label}
</span>
</div>
</div>
<button
type="button"
onClick={toggleFavorite}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"pointer-events-auto hidden shrink-0 rounded-lg p-2 transition-colors sm:block",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
<div className="pointer-events-auto shrink-0">
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
</div>
</div>
</li>
);
}
+15 -7
View File
@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatCardProps { interface StatCardProps {
label: string; label: string;
@@ -6,26 +7,33 @@ interface StatCardProps {
hint?: string; hint?: string;
icon: LucideIcon; icon: LucideIcon;
color: string; color: string;
className?: string;
} }
export function StatCard({ label, value, hint, icon: Icon, color }: StatCardProps) { export function StatCard({ label, value, hint, icon: Icon, color, className }: StatCardProps) {
return ( return (
<div className="glass relative overflow-hidden p-4"> <div className={cn("glass relative overflow-hidden p-3 sm:p-4", className)}>
<div <div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl" className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
aria-hidden="true" aria-hidden="true"
/> />
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500"> {/* Umbrechen statt abschneiden: in der zweispaltigen Anordnung auf
dem Telefon endeten "In Entwicklung" und "Repos verknuepft"
sonst beide als "In Entwickl…" – zwei Kacheln, die sich nicht
mehr unterscheiden liessen. */}
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label} {label}
</p> </p>
<p className="mt-2 text-3xl font-semibold tabular-nums text-white">{value}</p> <p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:mt-2 sm:text-3xl">
{hint ? <p className="mt-1 truncate text-xs text-slate-500">{hint}</p> : null} {value}
</p>
{hint ? <p className="mt-1 text-xs leading-tight text-slate-500">{hint}</p> : null}
</div> </div>
<span <span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl" className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }} style={{ backgroundColor: `${color}1f`, color }}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
+15 -6
View File
@@ -34,16 +34,18 @@ export function TopNav({
return ( return (
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-6 px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto flex w-full max-w-[1400px] items-center gap-3 px-4 py-3 sm:gap-6 sm:px-6 lg:px-8">
<Link href="/" className="group flex items-center gap-3"> <Link href="/" className="group flex min-w-0 items-center gap-2 sm:gap-3">
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105"> <span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} /> <Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
</span> </span>
<span className="flex flex-col leading-none"> <span className="flex min-w-0 flex-col leading-none">
<span className="text-gradient text-lg font-semibold tracking-tight"> <span className="text-gradient truncate text-lg font-semibold tracking-tight">
{appName} {appName}
</span> </span>
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500"> {/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die
Zeile wert ist – dort bleibt nur der Name stehen. */}
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 xs:block">
{tagline} {tagline}
</span> </span>
</span> </span>
@@ -51,13 +53,20 @@ export function TopNav({
{user ? ( {user ? (
<> <>
<nav className="flex items-center gap-1"> <nav className="flex shrink-0 items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => { {LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
key={href} key={href}
href={href} href={href}
// Unterhalb von sm bleibt nur das Symbol stehen. Ohne
// eigene Beschriftung ist der Verweis dann fuer einen
// Screenreader namenlos – axe-core meldet das als
// "link-name", und zwar zu Recht: vorgelesen wird nichts.
aria-label={label}
title={label}
aria-current={active ? "page" : undefined}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors", "flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active active
+2 -2
View File
@@ -53,7 +53,7 @@ export function Modal({
if (!mounted || !open) return null; if (!mounted || !open) return null;
return createPortal( return createPortal(
<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-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8">
<button <button
type="button" type="button"
aria-label="Dialog schliessen" aria-label="Dialog schliessen"
@@ -65,7 +65,7 @@ export function Modal({
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", "popover fade-up relative z-10 my-auto w-full rounded-2xl p-4 sm: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",
+15 -6
View File
@@ -44,7 +44,11 @@ export function Popover({
}) { }) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const [position, setPosition] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
@@ -56,14 +60,19 @@ export function Popover({
const margin = 8; const margin = 8;
const gap = 6; const gap = 6;
// Passt die Wunschbreite nicht ins Fenster, gilt das Fenster. Sonst
// liefert die Klemmung unten eine negative linke Kante und das Menue
// stuende zur Haelfte ausserhalb des Bildschirms.
const effektiv = Math.min(width, window.innerWidth - 2 * margin);
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln. // Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit // Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
// links stuende das Menue sonst quer ueber der Seite statt an seinem // links stuende das Menue sonst quer ueber der Seite statt an seinem
// Ausloeser. // Ausloeser.
let left = align === "end" ? rect.right - width : rect.left; let left = align === "end" ? rect.right - effektiv : rect.left;
if (left < margin) left = rect.left; if (left < margin) left = rect.left;
if (left + width > window.innerWidth - margin) left = rect.right - width; if (left + effektiv > window.innerWidth - margin) left = rect.right - effektiv;
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin); left = Math.min(Math.max(margin, left), window.innerWidth - effektiv - margin);
const panelHeight = panelRef.current?.offsetHeight ?? 0; const panelHeight = panelRef.current?.offsetHeight ?? 0;
let top = rect.bottom + gap; let top = rect.bottom + gap;
@@ -73,7 +82,7 @@ export function Popover({
const fitsAbove = rect.top - gap - panelHeight >= margin; const fitsAbove = rect.top - gap - panelHeight >= margin;
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight; if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
setPosition({ top, left }); setPosition({ top, left, width: effektiv });
}, [anchorRef, align, width]); }, [anchorRef, align, width]);
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim // Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
@@ -127,7 +136,7 @@ export function Popover({
position: "fixed", position: "fixed",
top: position?.top ?? -9999, top: position?.top ?? -9999,
left: position?.left ?? -9999, left: position?.left ?? -9999,
width, width: position?.width ?? width,
// Bis die Position steht, unsichtbar halten – sonst blitzt das // Bis die Position steht, unsichtbar halten – sonst blitzt das
// Menue kurz an der falschen Stelle auf. // Menue kurz an der falschen Stelle auf.
visibility: position ? "visible" : "hidden", visibility: position ? "visible" : "hidden",