20 Commits

Author SHA1 Message Date
Pepe Ziberi
5adadd246e fix(seed): prevent cascade-deletion of tenant_symbols on container restart
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m23s
2026-05-20 11:44:12 +02:00
Pepe Ziberi
4b92df8fea feat(schema): Phase 1 Symbol Architecture — SymbolTemplate, TenantCategory, TenantSymbol refactor + seed + migration scripts
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m40s
2026-05-20 11:11:32 +02:00
Pepe Ziberi
9b96de0a21 feat: Präsentationsmodus (Schloss-Button) + Version 1.3.5
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 16m59s
2026-05-20 08:35:52 +02:00
Pepe Ziberi
902e730cd3 trigger: force Gitea Actions run
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11m20s
2026-05-19 23:20:55 +02:00
Pepe Ziberi
1f891ab057 fix: track Pepe_Avatar.mp4 and allow public/*.mp4 in git 2026-05-19 23:19:57 +02:00
Pepe Ziberi
165109fc65 fix(docker): copy public folder after standalone to ensure assets are present
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 8m51s
2026-05-19 22:45:18 +02:00
Pepe Ziberi
b6fbe38f60 fix(docker): add missing Next.js runtime deps to runner image
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10m11s
2026-05-19 22:06:09 +02:00
Pepe Ziberi
053ae3729a fix(ci): revert REGISTRY_PASSWORD back to REGISTRY_TOKEN
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m23s
2026-05-19 21:16:54 +02:00
Pepe Ziberi
8207366362 chore(release): bump version to 1.3.4
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m26s
2026-05-19 21:03:06 +02:00
Pepe Ziberi
29217e883b refactor(ci): switch to Watchtower-only auto-update, remove Portainer webhook 2026-05-19 20:26:43 +02:00
Pepe Ziberi
0f635033c2 feat(ci): trigger Portainer webhook after successful image push 2026-05-19 20:12:16 +02:00
Pepe Ziberi
805559efc3 perf(ci): add Docker registry cache to speed up builds 2026-05-19 20:07:37 +02:00
Pepe Ziberi
5d46200905 chore(release): bump version to 1.3.3
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m19s
2026-05-19 19:54:14 +02:00
Pepe Ziberi
5c353a0da8 fix(ci): remove GitHub Actions cache from Gitea workflow
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 15m4s
2026-05-19 19:21:35 +02:00
Pepe Ziberi
ba6f095dc0 perf(docker): optimize runner stage build time
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 15m0s
2026-05-19 18:59:03 +02:00
Pepe Ziberi
362a7e4666 chore(ci): setup Gitea Actions + Portainer auto-deploy pipeline
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-19 18:07:56 +02:00
Pepe Ziberi
63a57dcb7c ci: switch to Portainer auto-build (like mein118.ch) 2026-04-26 19:40:57 +02:00
Pepe Ziberi
62a5a56dea ci: update deploy workflow for existing runner
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-04-26 19:27:35 +02:00
Pepe Ziberi
eb8566423f ci: add action for portainer deployment
Some checks failed
Build and Deploy to Portainer / build-and-deploy (push) Has been cancelled
2026-03-29 07:00:07 +02:00
Pepe Ziberi
1f508bca74 v1.3.2: SEO fixes + map bugfixes
SEO:
- Landing page converted to Server Component (SSR)
- Extracted NavAuthButtons + ContactForm as client islands
- Removed fake aggregateRating from JSON-LD
- Added FAQPage JSON-LD schema (7 questions)
- Extended sitemap: /datenschutz, /spenden, /demo

Map fixes:
- WebGL context lost recovery (black tiles after inactivity)
- Page visibility handler for tile reload on tab switch
- Arrow direction: geographic bearing instead of screen angle
- All markers rotationAlignment viewport->map (geographic orientation)
- DEL key now deletes selected lines/polygons/arrows (not just symbols)
- Default drawing color: black
2026-03-03 23:33:04 +01:00
30 changed files with 1639 additions and 265 deletions

13
.env.docker Normal file
View File

@@ -0,0 +1,13 @@
# Dummy environment for Docker build stage
# These values are only needed so Next.js can compile during docker build
# Runtime values are injected via docker-compose environment
DATABASE_URL=postgresql://lageplan:lageplan_secret@db:5432/lageplan
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=build-time-secret-not-used-at-runtime
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=lageplan-icons
MINIO_USE_SSL=false
MINIO_PUBLIC_URL=http://localhost:9000

View File

@@ -27,3 +27,8 @@ MINIO_PUBLIC_URL=http://localhost:9002
# Web App # Web App
WEB_PORT=3000 WEB_PORT=3000
NODE_ENV=development NODE_ENV=development
# --- CI/CD / Registry (nur für Portainer Deployment) ---
# Gitea Registry Login für Watchtower (automatische Image-Updates)
GITEA_REGISTRY_USER=adminpepe
GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort

View File

@@ -0,0 +1,49 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
workflow_dispatch:
env:
REGISTRY: git.purepixel.ch
IMAGE: git.purepixel.ch/adminpepe/lageplan
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest
type=sha,prefix=,suffix=,format=short
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max

5
.gitignore vendored
View File

@@ -49,7 +49,9 @@ prisma/migrations/*
# Large binary files (do not commit) # Large binary files (do not commit)
*.tar *.tar
*.zip *.zip
*.mp4 # Allow MP4s in public folder (used by the app)
!public/*.mp4
!public/**/*.mp4
lageplan-web.tar lageplan-web.tar
# Reference materials (keep locally, not in git) # Reference materials (keep locally, not in git)
@@ -58,4 +60,3 @@ Reglement_*/
# Stack env (contains secrets) # Stack env (contains secrets)
stack.env stack.env
.env.docker

View File

@@ -26,34 +26,41 @@ RUN npm run build
# Stage 3: Runner # Stage 3: Runner
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache openssl RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs && \
RUN adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public WORKDIR /app
COPY --from=builder /app/.next/standalone ./ # Fast: only chown the /app directory itself, not recursively
COPY --from=builder /app/.next/static ./.next/static RUN chown nextjs:nodejs /app
COPY --from=builder /app/.env ./.env
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --from=builder /app/node_modules/stripe ./node_modules/stripe
COPY --from=builder /app/package.json ./package.json
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
COPY server-custom.js ./server-custom.js
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chown -R nextjs:nodejs /app/node_modules
USER nextjs USER nextjs
# Install only the unbundled runtime deps needed by the custom server.
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
# Ensure all public files (videos, images, etc.) are present in the runtime image
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
COPY --chown=nextjs:nodejs --from=builder /app/.env ./.env
COPY --chown=nextjs:nodejs --from=builder /app/prisma ./prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/stripe ./node_modules/stripe
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/next ./node_modules/next
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react ./node_modules/react
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react-dom ./node_modules/react-dom
COPY --chown=nextjs:nodejs --from=builder /app/package.json ./package.json
COPY --chown=nextjs:nodejs server-custom.js ./server-custom.js
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"

View File

@@ -1,102 +1,168 @@
# Lageplan — Portainer Deployment # Lageplan — CI/CD & Portainer Deployment
## Architektur ## Übersicht
``` ```
Browser → :3000 (Web App) → intern: db:5432, minio:9000 ┌─────────────┐ Push ┌─────────────────┐ Build + Push ┌─────────────────┐
│ Dein PC │ ────────────► │ Gitea (git) │ ──────────────────► │ Gitea Registry │
│ (VS Code) │ │ git.purepixel │ │ (Docker Image) │
└─────────────┘ └─────────────────┘ └────────┬────────┘
│ Pull
┌─────────────────┐
│ Portainer │
│ (Stack Deploy) │
└─────────────────┘
``` ```
Nur **ein Port (3000)** muss exponiert werden. DB und MinIO laufen rein intern im Docker-Netzwerk. Icons werden über die Web-App gestreamt — kein direkter MinIO-Zugriff nötig. 1. **Push auf `main`** → Gitea Actions baut Docker Image
2. **Image wird gepusht** → Gitea Container Registry (`git.purepixel.ch`)
3. **Watchtower (in Portainer)** → prüft alle 60s auf neue Images und startet Container neu
--- ---
## Dateien ## Voraussetzungen
| Datei | Beschreibung | - Gitea läuft mit Container Registry aktiviert
|-------|-------------| - Gitea Actions Runner ist registriert (`deploy/docker-compose.runner.yml`)
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) | - Portainer Stack ist deployed mit korrekten Environment-Variablen
| `portainer-stack.yml` | Stack YAML für Portainer |
| `.env.example` | Environment Variables Vorlage |
--- ---
## Schritt 1: Image auf Server laden ## Schritt 1: Gitea Container Registry aktivieren
```bash In Gitea:
# Kopieren 1. **Admin-Konsole****Konfiguration****Pakete**
scp lageplan-web-v1.0.0.tar user@server:/tmp/ 2. **Container Registry** auf `Aktiviert` setzen
3. Speichern
# Auf dem Server laden Oder direkt in der `app.ini`:
docker load -i /tmp/lageplan-web-v1.0.0.tar ```ini
[packages]
ENABLED = true
# Prüfen [package.container_registry]
docker images | grep lageplan ENABLED = true
``` ```
--- ---
## Schritt 2: Stack in Portainer erstellen ## Schritt 2: Gitea Actions Runner registrieren
1. In Gitea: **Admin****Actions****Runners****Neuen Runner erstellen**
2. Token kopieren
3. In Portainer: Stack `gitea-runner` deployen mit [`deploy/docker-compose.runner.yml`](docker-compose.runner.yml)
4. Environment Variable `RUNNER_TOKEN` = das kopierte Token
---
## Schritt 3: Gitea Access Token erstellen
Das CI/CD Workflow braucht einen Token um Images in die Registry zu pushen:
1. Gitea → **Einstellungen****Anwendungen****Token erstellen**
2. Name: `registry-push`
3. Berechtigungen: `package:write` (mindestens)
4. Token kopieren und als **Repository Secret** hinterlegen:
- Repo → **Einstellungen****Secrets****Neues Secret**
- Name: `GITEA_TOKEN`
- Wert: das kopierte Token
---
## Schritt 4: Portainer Stack deployen
1. **Portainer**`Stacks``+ Add stack` 1. **Portainer**`Stacks``+ Add stack`
2. **Name**: `lageplan` 2. **Name**: `lageplan`
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen 3. **Build method**: `Repository`
4. **Environment variables** setzen: 4. **Git-URL**: `https://git.purepixel.ch/adminpepe/Lageplan.git`
5. **Compose path**: `docker-compose.portainer.yml`
6. **GitOps updates**: ✅ Aktivieren
7. **Mechanism**: `Webhook`
8. **Webhook URL kopieren** (für später)
| Variable | Wert | ### Environment Variables setzen:
|----------|------|
| `POSTGRES_USER` | `lageplan` |
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* |
| `POSTGRES_DB` | `lageplan` |
| `MINIO_ROOT_USER` | `minioadmin` |
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* |
| `MINIO_BUCKET` | `lageplan-icons` |
| `WEB_PORT` | `3000` |
| `NEXTAUTH_URL` | `http://SERVER_IP:3000` |
| `NEXTAUTH_SECRET` | *(langer zufälliger String)* |
> **Tipp**: Secret generieren: `openssl rand -base64 32` | Variable | Wert | Beschreibung |
|----------|------|-------------|
| `POSTGRES_USER` | `lageplan` | DB User |
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* | DB Passwort |
| `POSTGRES_DB` | `lageplan` | DB Name |
| `NEXTAUTH_URL` | `https://lageplan.ch` | Deine Domain |
| `NEXTAUTH_SECRET` | *(openssl rand -base64 32)* | Auth Secret |
| `MINIO_ROOT_USER` | `minioadmin` | MinIO User |
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* | MinIO Passwort |
| `MINIO_BUCKET` | `lageplan-icons` | Bucket Name |
| `MINIO_PUBLIC_URL` | `https://s3.deinedomain.ch` | MinIO externe URL |
| `GITEA_REGISTRY_USER` | `adminpepe` | Gitea User für Watchtower |
| `GITEA_REGISTRY_PASS` | *(Token oder Passwort)* | Gitea Passwort/Token |
5. **Deploy the stack** 9. **Deploy the stack**
--- ---
## Schritt 3: Datenbank initialisieren (einmalig) ## Schritt 5: Webhook in Gitea eintragen
In Portainer: Container `web` → Console → `/bin/sh`: Damit Portainer bei jedem Push automatisch neu deployed:
```bash 1. Gitea Repo → **Einstellungen****Webhooks****Neuer Webhook**`Gitea`
npx prisma db push 2. **Ziel-URL**: Die kopierte Portainer Webhook URL
npx prisma db seed 3. **HTTP-Methode**: `POST`
``` 4. **Trigger**: Nur `Push events` (oder auch `Branch filter: main`)
5. **Webhook aktivieren** → Hinzufügen
Oder per SSH:
```bash
docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed
```
--- ---
## Schritt 4: Zugriff ## Schritt 6: Erstes Deployment testen
- **Web App**: `http://SERVER_IP:3000` 1. Lokal einen Push auf `main` machen:
- **Login**: `admin@lageplan.local` / `admin123` ```bash
git add .
git commit -m "Test CI/CD"
git push origin main
```
2. In Gitea: **Actions** Tab → Build-Job sollte laufen
3. Wenn grün → Image wurde in Registry gepusht
4. Watchtower (in Portainer) holt neues Image innerhalb von 60s
5. App ist unter `NEXTAUTH_URL` erreichbar
--- ---
## Update ## Troubleshooting
### Gitea Actions startet nicht
- Prüfen ob Runner registriert ist: Gitea → Admin → Actions → Runners
- Runner muss `Idle` oder `Active` zeigen
### Image Push schlägt fehl (401 Unauthorized)
- `GITEA_TOKEN` Secret im Repo korrekt hinterlegt?
- Token hat Berechtigung `package:write`?
- Registry in Gitea aktiviert?
### Watchtower zieht kein neues Image
- `GITEA_REGISTRY_USER` und `GITEA_REGISTRY_PASS` in Portainer gesetzt?
- Image-Name in `docker-compose.portainer.yml` korrekt?
- Watchtower Logs prüfen: Portainer → Container `watchtower` → Logs
### App startet nicht / DB-Fehler
- Environment Variables in Portainer korrekt?
- `DATABASE_URL` wird automatisch gebaut, nur `POSTGRES_*` muss gesetzt werden
- Bei erstem Start: Prisma Migrations/Seed im Web-Container ausführen:
```bash
docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed
```
---
## Manuelles Update (falls nötig)
Wenn Watchtower mal nicht greift:
```bash ```bash
# Lokal: neues Image bauen + exportieren # Auf dem Portainer-Host
docker compose build web docker pull git.purepixel.ch/adminpepe/lageplan:latest
docker tag lageplan-web:latest lageplan-web:v1.1.0 docker compose -f docker-compose.portainer.yml up -d web
docker save lageplan-web:v1.1.0 -o deploy/lageplan-web-v1.1.0.tar
# Server: laden
docker load -i lageplan-web-v1.1.0.tar
# Portainer: Stack → Editor → Image-Tag ändern → Update the stack
``` ```
--- ---

View File

@@ -0,0 +1,23 @@
##############################################
# Gitea Actions Runner — Portainer Stack
#
# In Portainer deployen:
# 1. Stacks → Add Stack → "gitea-runner"
# 2. Diese YAML einfügen
# 3. Deploy
##############################################
services:
runner:
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://git.purepixel.ch
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
GITEA_RUNNER_NAME: lageplan-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- runner_data:/data
volumes:
runner_data:

View File

@@ -7,3 +7,8 @@ MINIO_BUCKET=lageplan-icons
WEB_PORT=3000 WEB_PORT=3000
NEXTAUTH_URL=http://SERVER_IP:3000 NEXTAUTH_URL=http://SERVER_IP:3000
NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN
MINIO_PUBLIC_URL=http://SERVER_IP:9000
# Gitea Registry Auth für Watchtower (automatische Image-Updates)
GITEA_REGISTRY_USER=adminpepe
GITEA_REGISTRY_PASS=HIER_GITEA_TOKEN_ODER_PASSWORT_SETZEN

View File

@@ -1,5 +1,5 @@
############################################## ##############################################
# Gitea — Lightweight Git Server # Gitea — Lightweight Git Server + Container Registry
# #
# Verwendung in Portainer: # Verwendung in Portainer:
# 1. Stacks → Add Stack → "Gitea" # 1. Stacks → Add Stack → "Gitea"
@@ -12,6 +12,10 @@
# 3. Repository "lageplan" erstellen # 3. Repository "lageplan" erstellen
# 4. Vom PC aus: git init → git remote add origin → git push # 4. Vom PC aus: git init → git remote add origin → git push
# #
# Container Registry aktivieren:
# 1. Gitea Admin → Konfiguration → Pakete → Container Registry aktivieren
# 2. Oder app.ini: [packages] ENABLED = true
#
# Daten werden in gitea_data persistiert. # Daten werden in gitea_data persistiert.
############################################## ##############################################
@@ -27,6 +31,9 @@ services:
- GITEA__server__ROOT_URL=https://git.purepixel.ch - GITEA__server__ROOT_URL=https://git.purepixel.ch
- GITEA__server__HTTP_PORT=3000 - GITEA__server__HTTP_PORT=3000
- GITEA__server__LFS_START_SERVER=true - GITEA__server__LFS_START_SERVER=true
# Container Registry aktivieren
- GITEA__packages__ENABLED=true
- GITEA__package__container_registry__ENABLED=true
volumes: volumes:
- gitea_data:/data - gitea_data:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
@@ -43,4 +50,3 @@ volumes:
networks: networks:
lageplan_lageplan-net: lageplan_lageplan-net:
external: true external: true

View File

@@ -1,21 +1,28 @@
############################################## ##############################################
# Lageplan — Portainer Stack Configuration # Lageplan — Portainer Stack (Watchtower Auto-Update)
# #
# Verwendung in Portainer: # Setup in Portainer:
# 1. Stacks → Add Stack # 1. Stacks → Add Stack → "Repository"
# 2. "Upload" oder diesen Inhalt einfügen # 2. Git-URL: https://git.purepixel.ch/adminpepe/Lageplan.git
# 3. Environment-Variablen setzen (siehe unten) # 3. Compose-Pfad: docker-compose.portainer.yml
# 4. Deploy # 4. "GitOps updates" aktivieren
# 5. Environment-Variablen setzen (siehe unten)
# 6. Deploy
#
# Danach: Push auf main → Gitea Actions baut Image →
# Watchtower erkennt neues Image und startet Container neu
# #
# Benötigte Environment-Variablen: # Benötigte Environment-Variablen:
# POSTGRES_USER (default: lageplan) # POSTGRES_USER (default: lageplan)
# POSTGRES_PASSWORD (ÄNDERN!) # POSTGRES_PASSWORD (ÄNDERN!)
# POSTGRES_DB (default: lageplan) # POSTGRES_DB (default: lageplan)
# NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32) # NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32)
# NEXTAUTH_URL (z.B. https://lageplan.example.com) # NEXTAUTH_URL (z.B. https://lageplan.ch)
# MINIO_ROOT_USER (default: minioadmin) # MINIO_ROOT_USER (default: minioadmin)
# MINIO_ROOT_PASSWORD (ÄNDERN!) # MINIO_ROOT_PASSWORD (ÄNDERN!)
# MINIO_PUBLIC_URL (z.B. https://s3.example.com) # MINIO_PUBLIC_URL (z.B. https://s3.example.com)
# GITEA_REGISTRY_USER (für Watchtower Registry-Auth)
# GITEA_REGISTRY_PASS (für Watchtower Registry-Auth)
############################################## ##############################################
services: services:
@@ -76,8 +83,9 @@ services:
- lageplan - lageplan
# ─── Lageplan Web App ────────────────────── # ─── Lageplan Web App ──────────────────────
# Image kommt aus Gitea Container Registry (gebaut via Gitea Actions)
web: web:
image: 192.168.1.183:3100/adminpepe/lageplan:latest image: git.purepixel.ch/adminpepe/lageplan:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
@@ -99,6 +107,25 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- lageplan - lageplan
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ─── Watchtower (Auto-Restart bei neuen Images) ─
# Überwacht nur Container mit Label com.centurylinklabs.watchtower.enable=true
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_POLL_INTERVAL: 60
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_LABEL_ENABLE: "true"
# Gitea Registry Auth
REPO_USER: ${GITEA_REGISTRY_USER}
REPO_PASS: ${GITEA_REGISTRY_PASS}
networks:
- lageplan
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -0,0 +1,269 @@
# Roadmap — Feedback Fabian (Mai 2026)
> **Quelle**: User-Feedback von Fabian, Führungsoffizier / Übungsleiter Feuerwehr.
> **Erhalten**: 20.05.2026
> **Status**: Geplant, noch nicht umgesetzt.
Dieses Dokument ist die strukturierte Roadmap aus Fabians Feedback. Jede KI / jeder Entwickler der an Lageplan arbeitet, soll diese Datei vor Beginn lesen, um Kontext und Priorisierung zu kennen.
---
## Originalfeedback (Zusammenfassung)
Fabian sucht eine Lösung um **Einsätze und Übungen einfach zu skizzieren und zu krokieren**. Lobt die App, gibt aber konkrete Verbesserungsvorschläge aus Anwendersicht:
1. **Einsatz vs. Übung unterscheiden** — bei Übungen kein Journal nötig, dafür Übungsziele/Auswertung
2. **Symbol-Bibliothek erweitern** — Warteraum, Rettungsachse, taktische Zeichen
3. **Symbol-Schönheitsfehler** — Treppe/Eingang mit Umriss, Absperrung
4. **Stockwerke/Geschosse** — eigene Werte eintragen
5. **Symbol-Kategorien aufräumen** — Motorspritze unter Organisation statt Geräte; Hydrant/Leitern uneinheitlich
6. **Linien mit Typ** — nach Zeichnen wählen: Rettungsachse (gestrichelt + R), Leitung (blau), Schlauch, etc.
7. **Multi-User Live** — eine Person zeichnet Karte, andere schreibt Journal gleichzeitig
8. **Rapport-/Lageansicht** — separate Sicht mit Pendenzen, aktueller Stand, wichtige Punkte für Führungsunterstützung (FU), darstellbar auf grossem Bildschirm; nicht alle Journaleinträge sichtbar für alle
---
## Phasen-Plan
### Phase 1 — Symbol-Architektur Redesign (3-4 Wochen) ⭐⭐
**Strategischer Umbau** statt nur Kategorien aufräumen. Begründung:
- Fabians Feedback zeigt, dass die aktuelle Kategorisierung uneinheitlich ist
- Symbol-Bibliothek ist zu starr — andere Anwender (THW, Sanität, ausländische Wehren) brauchen andere Symbole
- Mandantenfähigkeit ist zentrales Versprechen → muss auch für Symbole konsequent durchgezogen werden
#### 1.1 Neue Symbol-Architektur: Template-Import + 100% mandantenspezifisch
**Konzept**: Mandant startet mit leerer Bibliothek, importiert beim Onboarding (oder jederzeit) **kuratierte Vorlagen-Pakete** (Feuerwehr CH, THW, Sanität…). Importierte Symbole werden vollständig eigene Mandanten-Daten — umbenennbar, löschbar, kategorisierbar, ergänzbar.
**Datenmodell**:
```
TenantCategory (per-tenant, frei definierbar)
- id, tenantId, name, sortOrder, icon
TenantSymbol (per-tenant, ehemals "Meine Symbole")
- id, tenantId, categoryId → TenantCategory
- name, svgPath, sortOrder, customName
SymbolTemplate (global, read-only)
- id, packageId ("feuerwehr-ch", "thw", "sanitaet")
- categoryName, name, svgPath, tags
```
**Tasks**:
- [ ] Schema-Migration: `TenantCategory` neu, `TenantSymbol.categoryId` ergänzen, Standard-`Icon` zu `SymbolTemplate` umwandeln
- [ ] **Migration der bestehenden Tenants**: Auto-Import des `feuerwehr-ch` Pakets beim ersten Login, sodass nichts kaputt geht
- [ ] API: CRUD für `TenantCategory`, erweitert für `TenantSymbol`
- [ ] API: `GET /api/templates` — listet verfügbare Pakete
- [ ] API: `POST /api/templates/import` — importiert ausgewählte Symbole als TenantSymbols
- **Files**: `prisma/schema.prisma`, `src/app/api/tenant/categories/`, `src/app/api/tenant/symbols/`, `src/app/api/templates/`
#### 1.2 UX: Symbol-Manager im Admin
**Sidebar / Symbol-Verwaltung**:
```
┌─ Meine Symbole ─────────────┐
│ ┌ Fahrzeuge ─────────┐ │
│ │ 🚒 TLF │ │
│ │ 🚒 RW │ │
│ │ + Symbol │ │
│ └────────────────────┘ │
│ ┌ Wasser ────────────┐ │
│ │ 🟦 Hydrant │ │
│ └────────────────────┘ │
│ [+ Kategorie] │
│ [📦 Vorlagen importieren] │
└─────────────────────────────┘
```
**Tasks**:
- [ ] Admin-Tab: Kategorien anlegen/umbenennen/sortieren (Drag & Drop)
- [ ] Symbole pro Kategorie verwalten (Drag & Drop zwischen Kategorien)
- [ ] Eigene SVG-Uploads direkt einer Kategorie zuordnen
- [ ] Import-Dialog: Pakete-Auswahl mit Vorschau, granulare Symbol-Auswahl
- [ ] Mehrfach-Import desselben Symbols erlaubt (z.B. "TLF rot" + "TLF blau" auf gleicher SVG-Basis)
- **Files**: `src/components/admin/icons-tab.tsx` (umbauen), neuer `import-templates-dialog.tsx`
#### 1.3 Vorlagen-Pakete kuratieren
Aus dem aktuellen Symbol-Bestand werden die ersten Pakete:
- [ ] 📦 **Feuerwehr Schweiz** — taktische Zeichen CH (aktueller Bestand, sauber kategorisiert)
- [ ] 📦 **Sanität / Rettungsdienst** — falls Symbole vorhanden, sonst Phase 2
- [ ] 📦 **Polizei / Verkehr** — als Stub für später
- [ ] Innerhalb der Pakete: **fehlende taktische Zeichen ergänzen**:
- Warteraum
- Rettungsachse
- Sammelplatz
- Verletztennest / Eingang Verletzter
- Einsatzleitung (EL) / Kommandoposten
- Bereitstellungsraum
- [ ] **Symbol-Schönheitsfehler im Paket** beheben:
- Treppe — Umriss entfernen
- Eingang — Umriss entfernen
- Absperrung — Seitenlinien aufräumen
- **Files**: `prisma/seed/symbol-templates.ts`, `public/icons/`
#### 1.4 Stockwerke/Geschosse editierbar
- [ ] Symbol-Property: `instanceLabel` (Freitext, pro Symbol-Instanz auf der Karte)
- [ ] Bei Gebäude-Symbolen: optionales Textfeld für Geschosszahl (z.B. "EG+3")
- [ ] Anzeige als Badge auf dem Symbol
- [ ] Editierbar via Doppelklick oder Sidebar
- **Files**: `src/types/`, `map-view.tsx` Symbol-Renderer, ggf. DrawFeature.properties erweitern
---
### Phase 1.5 — Quick Polish (parallel oder danach, 1 Woche) 🔥
Kleinkram der nicht in den Architektur-Umbau passt aber wichtig ist.
- [ ] Default-Linientyp / -farbe pro Mandant konfigurierbar
- [ ] Symbol-Suche im Sidebar (Volltext über Name, Tags, Kategorie)
- [ ] Häufig-benutzte Symbole oben anzeigen (Recent / Favoriten)
---
### Phase 2 — Übungsmodus (2-3 Wochen) ⭐
Neuer Projekt-Typ, eigene Logik.
#### 2.1 Projekt-Typ Auswahl
- [ ] Bei "Neues Projekt": Auswahl `Einsatz | Übung`
- [ ] DB-Schema: `Project.type: 'einsatz' | 'uebung'`
- [ ] UI-Anpassung in `app/page.tsx`
#### 2.2 Übungs-Metadaten
- [ ] Felder: Übungstitel, Datum, **Übungsziele (Liste)**
- [ ] Übungsleiter, Teilnehmer (optional)
- [ ] Eigene Sidebar-Sektion für Übungsdaten
#### 2.3 Journal ausblenden bei Übung
- [ ] Bei `type === 'uebung'`: Journal-Tab → "Übungsauswertung"
- [ ] Karte bleibt voll funktionsfähig
- [ ] Krokierung kann **im Voraus** erstellt und mit Übungsleitern geteilt werden (bestehender Share-Link)
#### 2.4 Übungsauswertung
- [ ] Checkliste **Übungsziele** mit Status: `erreicht | teilweise | nicht erreicht`
- [ ] Notizen / Erkenntnisse pro Ziel
- [ ] Gesamtbewertung / Erkenntnisse
- [ ] **PDF-Export** für Debriefing (analog Rapport)
- **Files**: neue Komponente `uebungsauswertung-tab.tsx`, neuer PDF-Renderer
---
### Phase 3 — Linien mit Typ (1 Woche) ⭐
Smart Lines mit vordefinierten Stilen.
#### 3.1 Linientypen definieren
- [ ] **Schlauch** (default, aktuelle Linie)
- [ ] **Rettungsachse** — gestrichelt, Label "R"
- [ ] **Leitung** — blau
- [ ] **Absperrung** — rot gestrichelt
- [ ] **Frei** — Standard ohne Vorlage
#### 3.2 UX
- [ ] Nach Zeichnen einer Linie: kleines Popup "Was ist das?"
- [ ] Auswahl per Klick → Linie wird automatisch gestylt
- [ ] In Sidebar pro Linie nachträglich änderbar (Dropdown)
- [ ] Tastatur-Shortcut für Typ-Wechsel?
#### 3.3 Daten-Modell
- [ ] `DrawFeature` erweitern: `lineCategory: 'hose' | 'rescue' | 'pipe' | 'barrier' | 'free'`
- [ ] Default-Stile als Konstante (Farbe, Strich, Label)
- **Files**: `src/types/`, `tool-store.ts`, `map-view.tsx`
---
### Phase 4 — Rapport-/Lageansicht (Führungsunterstützung) (2-3 Wochen) ⭐
Eigenes Modul, hoher Mehrwert für FU.
#### 4.1 Sichtbarkeits-Stufen pro Journaleintrag
- [ ] DB: `JournalEntry.visibility: 'intern' | 'rapport' | 'public'`
- [ ] UI: Dropdown beim Erstellen / Editieren
- [ ] Migration: bestehende Einträge → `intern` (sicher)
#### 4.2 Rapport-Modus (Display)
- [ ] Neuer Tab/Modus: **Rapport-Ansicht**
- [ ] Sektionen:
- **Aktuelle Lage** (Freitext, prominentes Feld)
- **Pendenzen** (Liste mit Status: offen/erledigt)
- **Wichtige Punkte / Befehle**
- **Mittel / Personal** (aktueller Stand)
- [ ] Grosse Schrift, kontrastreich (Beamer-tauglich)
- [ ] Auto-Refresh via Socket.IO
#### 4.3 Display-Sharing
- [ ] Read-only Token-Link (wie bestehende Rapport-URL)
- [ ] Vollbild-Modus
- [ ] Optional: Auswahl welche Sektionen sichtbar
#### 4.4 Berechtigungen
- [ ] Rolle "Rapport-Viewer" — sieht nur Rapport, nicht Karte / Journal
- [ ] Berechtigung pro Projekt vergebbar
---
### Phase 5 — Multi-User Live-Editing polish (optional, später)
Realtime-Sync läuft schon via Socket.IO — hier nur Verbesserungen.
- [ ] **Live-Cursor** anderer User auf Karte (mit Name)
- [ ] **Presence-Anzeige** ("Fabian schreibt im Journal", "Pepe zeichnet")
- [ ] **Soft-Locks** während Symbol bewegt wird
- [ ] **Conflict Resolution** bei gleichzeitigem Edit testen
---
## Priorisierungs-Matrix
| Phase | Aufwand | Impact | Priorität | Empfohlene Reihenfolge |
|-------|---------|--------|-----------|------------------------|
| 1 — Symbol-Architektur Redesign | L (3-4 W) | Sehr Hoch | ⭐⭐ Höchste | **1.** |
| 1.5 — Quick Polish | S | Mittel | 🔥 Hoch | parallel zu 1 |
| 3 — Linien-Typen | M | Hoch | ⭐ Hoch | **2.** |
| 4 — Rapport-Ansicht | L | Sehr Hoch (FU) | ⭐ Sehr Hoch | **3.** |
| 2 — Übungsmodus | L | Sehr Hoch | ⭐ Sehr Hoch | **4.** |
| 5 — Multi-User polish | M | Mittel | 📅 Mittel | später |
**Begründung der Reihenfolge**:
1. **Phase 1 zuerst** — Architektur-Umbau, weil alles andere darauf aufbaut. Sobald TenantSymbols + Kategorien sauber strukturiert sind, sind 1.3 (fehlende Symbole) und 1.4 (Stockwerke) trivial. Auch Phase 2 (Übungsmodus) profitiert: Übungs-Pakete als eigene Templates möglich.
2. **Phase 3 (Linien-Typen)** — kleine Erweiterung, hoher UX-Gewinn, unabhängig von Phase 1.
3. **Phase 4 (Rapport-Ansicht)** — USP gegenüber anderen Krokier-Tools, FU-Funktion fehlt überall sonst.
4. **Phase 2 (Übungsmodus)** — grosses Update; baut konzeptuell auf Phase 4 auf (Übungsauswertung ≈ Rapport).
5. **Phase 5** — Polish, kein User wartet darauf.
**Risiko-Hinweis Phase 1**:
- Schema-Migration von `Icon` (global) zu `SymbolTemplate` + Auto-Import muss **wasserdicht** sein, damit existierende Mandanten ihre Symbole nicht verlieren
- Vor Deploy: vollständiges DB-Backup (PostgreSQL) und MinIO-Backup (Icon-Files)
- Auf Staging testen falls möglich, sonst kleinen Test-Tenant zuerst migrieren
---
## Hinweise für Entwickler / KIs
- **Stack**: Next.js 15, React 19, MapLibre GL JS, Prisma, PostgreSQL, MinIO, Socket.IO, Zustand
- **Sprache**: Schweizerdeutsch / Hochdeutsch (Code englisch, UI deutsch)
- **Bei Symbol-Änderungen**: SVGs in `public/icons/` + Symbol-Definitionen + ggf. DB-Migration
- **Bei Schema-Änderungen**: Prisma Migration erstellen + DB-Backup vor Deploy
- **Vor Deploy**: Build testen (`npx next build`), commit + push, Portainer baut automatisch via Webhook
- **Realtime**: bei DB-Änderungen, die alle User sehen müssen, Socket-Event triggern
---
## Status-Tracking
> Status pro Aufgabe oben in Checkboxen. Hier kurzer Überblick:
- [ ] **Phase 1** — nicht gestartet
- [ ] **Phase 2** — nicht gestartet
- [ ] **Phase 3** — nicht gestartet
- [ ] **Phase 4** — nicht gestartet
- [ ] **Phase 5** — nicht gestartet
---
**Letztes Update**: 2026-05-20
**Verantwortlich**: Pepe (adminpepe)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.2",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.3.1", "version": "1.3.5",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -0,0 +1,370 @@
# Phase 1 — Symbol-Architektur Redesign: Detaillierter Plan
> Basierend auf: `docs/roadmap-feedback-fabian.md`
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
---
## 1. Ziel & Konzept
**Problem heute:**
- `TenantSymbol` verweist auf `IconAsset` (global). Mandant kann zwar `customName` setzen, aber nicht die Kategorie ändern, das SVG bearbeiten oder Symbole aus verschiedenen Paketen frei mischen.
- Kategorien (`IconCategory`) sind global mit `tenantId` Override — uneinheitlich.
- Keine Möglichkeit, Vorlagen-Pakete (Feuerwehr CH, THW, Sanität) als Unit zu importieren.
**Lösung:**
- `SymbolTemplate` = globale, read-only Vorlagen (je Paket). Wird einmalig aus bestehenden `public/signaturen/` und `IconAsset` generiert.
- `TenantCategory` = pro Mandant, frei anlegbar/umbenennbar/sortierbar.
- `TenantSymbol` = pro Mandant, **vollständig eigenständig** (`name`, `svgPath`, `categoryId`). Kein Verweis mehr auf globale `IconAsset`.
- Mandant startet mit leerer Bibliothek und importiert Pakete nach Bedarf.
---
## 2. Datenmodell-Änderungen
### 2.1 Neue Modelle
```prisma
model SymbolTemplate {
id String @id @default(uuid())
packageId String // z.B. "feuerwehr-ch"
packageName String // z.B. "Feuerwehr Schweiz"
categoryName String // z.B. "Fahrzeuge"
name String
svgPath String // Relativer Pfad in public/ oder SVG-Inhalt
tags String[] @default([])
sortOrder Int @default(0)
@@index([packageId])
@@map("symbol_templates")
}
model TenantCategory {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
sortOrder Int @default(0)
icon String? // Optional: Emoji/Lucide-Icon-Name für UI
symbols TenantSymbol[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("tenant_categories")
}
```
### 2.2 Bestehendes `TenantSymbol` umbauen
**Vorher:**
```prisma
model TenantSymbol {
id String @id @default(uuid())
customName String?
sortOrder Int @default(0)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
}
```
**Nachher:**
```prisma
model TenantSymbol {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
categoryId String
category TenantCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
svgPath String // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
sortOrder Int @default(0)
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Legacy-Feld für Migration (nach erfolgreichem Deploy entfernen)
migratedFromIconId String?
@@index([tenantId])
@@index([categoryId])
@@map("tenant_symbols")
}
```
> **Hinweis:** `IconAsset`, `IconCategory` und `hiddenIconIds` bleiben vorerst bestehen (read-only Legacy), werden aber nicht mehr für neue Features verwendet. In einer späteren Phase können sie entfernt werden.
---
## 3. Migrationstrategie
### 3.1 Vor dem Deploy
- **PostgreSQL-Backup** erstellen
- **MinIO-Backup** der Icon-Dateien
- Auf Staging testen (falls vorhanden)
### 3.2 Schritt-für-Schritt-Migration (in einer Transaktion)
1. **Neue Tabellen anlegen** (`SymbolTemplate`, `TenantCategory`, neue `TenantSymbol`-Spalten)
2. **SymbolTemplate füllen:**
- Alle `public/signaturen/*.svg` einlesen
- Bestehende `IconAsset` mit `isSystem = true` als `feuerwehr-ch` Paket überführen
- Zuordnung: `categoryName` aus `IconCategory.name`
3. **TenantCategory pro Tenant anlegen:**
- Für jeden Tenant eine Default-Kategorie "Meine Symbole" erstellen
- Optional: Weitere Kategorien aus `IconCategory` ableiten (nur wenn `tenantId` gesetzt)
4. **TenantSymbol migrieren:**
- Für jeden bestehenden `TenantSymbol`-Eintrag:
- `name` = `customName || icon.name`
- `svgPath` = `icon.fileKey`
- `categoryId` = Default-Kategorie des Tenants (oder `icon.categoryId` wenn passend)
- `migratedFromIconId` = `iconId` (für Nachvollziehbarkeit)
5. **App-Code auf neue Modelle umstellen**
6. **Alte Relation `TenantSymbol.icon` und `TenantSymbol.iconId` entfernen** (nach erfolgreichem Live-Test)
### 3.3 Rollback-Plan
Falls etwas schiefgeht: Backup wiederherstellen. Die Migration ist idempotent (neue Tabellen, alte bleiben erhalten).
---
## 4. API-Design
### 4.1 Templates
```
GET /api/templates
→ { packages: [{ id, name, description, symbolCount, previewUrls }] }
GET /api/templates?packageId=feuerwehr-ch
→ { packageId, packageName, categories: [{ categoryName, symbols: [{ id, name, svgPath, tags }] }] }
POST /api/templates/import
Body: { packageId: "feuerwehr-ch", symbolIds?: ["id1", "id2"] }
→ importiert ausgewählte Symbole als TenantSymbols (oder alle wenn symbolIds fehlt)
→ antwortet mit [{ tenantSymbolId, name, categoryId }]
```
### 4.2 Tenant Categories (Admin)
```
GET /api/tenant/categories
→ [{ id, name, sortOrder, icon, symbolCount }]
POST /api/tenant/categories
Body: { name, sortOrder?, icon? }
→ { id, name, sortOrder, icon }
PATCH /api/tenant/categories/:id
Body: { name?, sortOrder?, icon? }
→ updated category
DELETE /api/tenant/categories/:id
→ 204 (nur erlaubt wenn leer, sonst 409)
```
### 4.3 Tenant Symbols (Admin + Sidebar)
```
GET /api/tenant/symbols
→ { categories: [{ id, name, sortOrder, symbols: [{ id, name, svgPath, sortOrder, isUploaded }] }] }
// Gruppiert nach TenantCategory
POST /api/tenant/symbols
Body: { templateId } // Import aus Template
Body: { name, svgPath, categoryId } // Manuelle Erstellung
→ { id, name, svgPath, categoryId, sortOrder }
POST /api/tenant/symbols/upload
Multipart: { file (SVG/PNG), name, categoryId }
→ uploaded TenantSymbol
PATCH /api/tenant/symbols/:id
Body: { name?, categoryId?, sortOrder? }
→ updated
DELETE /api/tenant/symbols/:id
→ 204
```
### 4.4 Icon-Serving (unchanged path für Kompatibilität)
```
GET /api/icons/:tenantSymbolId/image
→ Liest `TenantSymbol.svgPath` und serviert Datei (aus public/ oder MinIO)
```
---
## 5. Frontend-Änderungen
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
**Umbau in 3 Bereiche:**
```
┌─ Symbol-Manager ─────────────────────────────────┐
│ │
│ [+ Kategorie anlegen] [📦 Vorlagen importieren] │
│ │
│ ┌─ Fahrzeuge ─────────────┐ [⋮] [✎] [🗑] │
│ │ 🚒 TLF [✎] [🗑] [↕] │
│ │ 🚒 RW [✎] [🗑] [↕] │
│ │ [+ Symbol hinzufügen] │
│ └──────────────────────────┘ │
│ ┌─ Wasser ─────────────────┐ [⋮] [✎] [🗑] │
│ │ 🟦 Hydrant [✎] [🗑] [↕] │
│ └──────────────────────────┘ │
│ │
│ [Eigenes SVG hochladen] │
│ │
└────────────────────────────────────────────────────┘
```
**Features:**
- Kategorien per Drag & Drop sortieren (`@dnd-kit` oder native)
- Symbole zwischen Kategorien verschieben (Drag & Drop)
- Kategorie anlegen/umbenennen/löschen (nur wenn leer)
- Symbol umbenennen, Kategorie ändern, löschen
- "Vorlagen importieren" öffnet Dialog mit Paket-Vorschau
### 5.2 Admin — Import-Dialog (`src/components/admin/import-templates-dialog.tsx`)
```
┌─ Vorlagen importieren ─────────────────────────────┐
│ │
│ [📦 Feuerwehr Schweiz] [📦 THW] [📦 Sanität] │
│ │
│ ┌─ Feuerwehr Schweiz ───────────────────────────┐│
│ │ Kategorie: Fahrzeuge (3 Symbole) ││
│ │ ☑️ TLF ☑️ RW ☐ DLK ││
│ │ Kategorie: Wasser (5 Symbole) ││
│ │ ☑️ Hydrant ☑️ Löschwasser ││
│ │ ││
│ │ [Alle auswählen] [Ausgewählte importieren] ││
│ └────────────────────────────────────────────────┘│
│ │
└────────────────────────────────────────────────────┘
```
### 5.3 Sidebar / LeftToolbar — Symbol-Palette umbauen
Aktuell gibt es zwei Komponenten:
- `LeftToolbar` = Zeichenwerkzeuge (Bleistift, Linie, etc.)
- Symbol-Palette = vermutlich in `map-view.tsx` oder separater Komponente
**Ziel:** Die Symbol-Palette (die Symbole die auf die Karte gezogen werden) muss nach `TenantCategory` gruppiert werden.
Da die Symbol-Palette vermutlich inline in `map-view.tsx` oder einer anderen Komponente ist, suchen und extrahieren in eine eigene `SymbolPalette`-Komponente:
```tsx
// src/components/map/symbol-palette.tsx
interface SymbolPaletteProps {
categories: TenantCategoryWithSymbols[]
onSymbolDragStart: (symbol: TenantSymbol) => void
canEdit: boolean
}
```
**Layout:**
- Collapsible Kategorien (wie aktuell in Symbol-Manager)
- Symbole als Grid pro Kategorie
- Search-Input oben (Volltext über Name + Tags)
- Recent/Favoriten-Sektion (später in Phase 1.5)
### 5.4 DrawFeature — `instanceLabel` für Stockwerke (Phase 1.4)
Erweiterung des `properties`-Objekts:
```ts
interface SymbolProperties {
iconId: string
scale: number
rotation: number
instanceLabel?: string // z.B. "EG+3", "Wohngebäude A"
}
```
Renderer: Badge auf dem Symbol-Overlay (MapLibre Marker oder CSS-Overlay).
Edit: Doppelklick auf Symbol → kleiner Inline-Edit oder Dialog.
---
## 6. Dateien: Create / Modify / Delete
### Neue Dateien
| Pfad | Beschreibung |
|------|-------------|
| `prisma/migrations/2026xxxx_symbol_architecture/` | Prisma Migration |
| `prisma/seed-symbol-templates.ts` | Seed-Skript: `public/signaturen/*.svg``SymbolTemplate` |
| `src/app/api/templates/route.ts` | GET /api/templates |
| `src/app/api/templates/import/route.ts` | POST /api/templates/import |
| `src/app/api/tenant/categories/route.ts` | CRUD TenantCategory |
| `src/app/api/tenant/categories/[id]/route.ts` | PATCH/DELETE einzelne Kategorie |
| `src/components/admin/import-templates-dialog.tsx` | Import-Dialog UI |
| `src/components/map/symbol-palette.tsx` | Extrahierte Symbol-Palette |
### Zu modifizierende Dateien
| Pfad | Änderung |
|------|----------|
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol umbauen |
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung nach Category, Upload, CRUD |
| `src/app/api/icons/route.ts` | Legacy-Modus, ggf. auf TenantSymbol umleiten |
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorie-Verwaltung |
| `src/app/app/page.tsx` | Symbol-Palette Props anpassen |
| `src/components/map/map-view.tsx` | Symbol-Rendering mit instanceLabel Badge |
| `src/types/index.ts` | Neue Typen: `TenantCategory`, `TenantSymbol`, `SymbolTemplate` |
---
## 7. Ausführungsreihenfolge (Execution Order)
### Sprint A — Schema & Daten (Woche 1)
1. `prisma/schema.prisma` erweitern (`SymbolTemplate`, `TenantCategory`, `TenantSymbol` Refactor)
2. Prisma Migration erstellen & testen (`npx prisma migrate dev`)
3. `prisma/seed-symbol-templates.ts` schreiben (feuerwehr-ch Paket aus `public/signaturen/`)
4. Migration-Skript für bestehende Tenants (Default-Kategorie + TenantSymbol-Migration)
5. Build testen, auf Staging deployen
### Sprint B — API (Woche 1-2)
6. `GET /api/templates` + `POST /api/templates/import`
7. `CRUD /api/tenant/categories`
8. `Refactor /api/tenant/symbols` (Gruppierung, Upload, Kategorie-Zuordnung)
9. `GET /api/icons/:id/image` an TenantSymbol anpassen
### Sprint C — Admin UI (Woche 2-3)
10. Symbol-Manager: Kategorie-Verwaltung (anlegen/umbenennen/löschen/sortieren)
11. Symbol-Manager: Import-Dialog (Paket-Vorschau, granulare Auswahl)
12. Symbol-Manager: Eigenen SVG-Upload mit Kategorie-Zuordnung
13. Symbol-Manager: Drag & Drop (Kategorien sortieren, Symbole verschieben)
### Sprint D — Frontend Sidebar & Polish (Woche 3-4)
14. `SymbolPalette`-Komponente extrahieren und nach Kategorien gruppieren
15. Symbol-Suche in Sidebar (Volltext)
16. `instanceLabel` / Stockwerke implementieren (Phase 1.4)
17. Häufig-benutzte Symbole (Recent) — Phase 1.5
18. End-to-End-Test, Deploy
---
## 8. Risiken & Entscheidungen
| Thema | Option A (empfohlen) | Option B |
|-------|---------------------|----------|
| **SVG-Speicherort** | `svgPath` = relativer Pfad in `public/signaturen/` (klein, schnell, kein MinIO nötig für Templates) | `svgPath` = MinIO-Key (konsistent mit Uploads, aber Overhead) |
| **TenantSymbol Uploads** | Eigenes MinIO-Bucket `tenant-{id}/symbols/` | In DB als Text speichern (Base64 oder SVG-String) |
| **Migration alter Tenants** | Auto-Import feuerwehr-ch Paket + Default-Kategorie | Manuelle Migration pro Tenant |
| **LeftToolbar vs SymbolPalette** | SymbolPalette als separate Komponente neben LeftToolbar | In LeftToolbar integrieren |
**Empfehlung:**
- Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead)
- Uploads: MinIO `tenant-{id}/symbols/`
- Migration: Vollautomatisch beim ersten Login nach Deploy (kein manueller Eingriff)
---
*Plan erstellt: 2026-05-20*
*Nächster Schritt: Genehmigung durch Pepe, dann Sprint A starten*

View File

@@ -0,0 +1,112 @@
/**
* Post-Migration Script: Migrate existing TenantSymbols to new architecture.
*
* Run AFTER applying the schema migration (20260520_symbol_architecture).
*
* Steps:
* 1. Create a default "Meine Symbole" TenantCategory for each tenant that has TenantSymbols
* 2. Migrate existing TenantSymbols: set name, svgPath, categoryId, migratedFromIconId
* 3. Verify consistency
*
* Usage:
* npx ts-node prisma/migrate-tenant-symbols.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('🔧 Migrating existing TenantSymbols...')
// Step 1: Get all tenants that have existing tenantSymbols
const tenantsWithSymbols = await (prisma as any).tenantSymbol.groupBy({
by: ['tenantId'],
_count: { id: true },
})
console.log(`Found ${tenantsWithSymbols.length} tenants with existing symbols`)
let categoriesCreated = 0
let symbolsMigrated = 0
for (const group of tenantsWithSymbols) {
const tenantId = group.tenantId
// Step 2: Create default category for this tenant
let defaultCategory = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: 'Meine Symbole' },
})
if (!defaultCategory) {
defaultCategory = await (prisma as any).tenantCategory.create({
data: {
tenantId,
name: 'Meine Symbole',
sortOrder: 0,
},
})
categoriesCreated++
}
// Step 3: Migrate all tenantSymbols for this tenant
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: { icon: true },
})
for (const ts of tenantSymbols) {
const updates: any = {}
// name = customName || icon.name
if (!ts.name) {
updates.name = ts.customName || ts.icon?.name || 'Unbenannt'
}
// svgPath = icon.fileKey
if (!ts.svgPath && ts.icon?.fileKey) {
updates.svgPath = ts.icon.fileKey
}
// categoryId = default category
if (!ts.categoryId) {
updates.categoryId = defaultCategory.id
}
// migratedFromIconId = current iconId
if (!ts.migratedFromIconId) {
updates.migratedFromIconId = ts.iconId
}
if (Object.keys(updates).length > 0) {
await (prisma as any).tenantSymbol.update({
where: { id: ts.id },
data: updates,
})
symbolsMigrated++
}
}
}
console.log(`✅ Done: ${categoriesCreated} categories created, ${symbolsMigrated} symbols migrated`)
// Verification
const unmappedCount = await (prisma as any).tenantSymbol.count({
where: { categoryId: null },
})
if (unmappedCount > 0) {
console.warn(`⚠️ ${unmappedCount} tenantSymbols still have no categoryId!`)
} else {
console.log('✅ All tenantSymbols have a category assigned')
}
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -231,6 +231,65 @@ async function migrate() {
console.log(' tenant_symbols table skipped:', e.message) console.log(' tenant_symbols table skipped:', e.message)
} }
// ─── Step 13: Create symbol_templates table ───
console.log(' [13] Creating symbol_templates table...')
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS symbol_templates (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"fileKey" TEXT NOT NULL,
"originalFilename" TEXT NOT NULL,
"displayName" TEXT,
"categoryName" TEXT,
"svgPath" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE("fileKey")
)
`)
console.log(' symbol_templates table created (or already exists)')
} catch (e) {
console.log(' symbol_templates table skipped:', e.message)
}
// ─── Step 14: Create tenant_categories table ───
console.log(' [14] Creating tenant_categories table...')
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS tenant_categories (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE
)
`)
console.log(' tenant_categories table created (or already exists)')
} catch (e) {
console.log(' tenant_categories table skipped:', e.message)
}
// ─── Step 15: Extend tenant_symbols with Phase 1 columns ───
console.log(' [15] Extending tenant_symbols with Phase 1 columns...')
const tenantSymbolColumns = [
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "svgPath" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT REFERENCES tenant_categories(id) ON DELETE SET NULL`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
]
let tsAdded = 0
for (const sql of tenantSymbolColumns) {
try { await prisma.$executeRawUnsafe(sql); tsAdded++ } catch (e) { /* ignore */ }
}
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`)
console.log('✅ Database migrations complete') console.log('✅ Database migrations complete')
} }

View File

@@ -90,6 +90,7 @@ model Tenant {
iconCategories IconCategory[] iconCategories IconCategory[]
iconAssets IconAsset[] iconAssets IconAsset[]
tenantSymbols TenantSymbol[] tenantSymbols TenantSymbol[]
tenantCategories TenantCategory[]
upgradeRequests UpgradeRequest[] upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[] dictionaryEntries DictionaryEntry[]
rapports Rapport[] rapports Rapport[]
@@ -392,10 +393,53 @@ model TenantSymbol {
iconId String iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade) icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
// New fields for Phase 1 Symbol Architecture
categoryId String?
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
name String? // Display name (migrated from customName || icon.name)
svgPath String? // e.g. "signaturen/TLF.svg" or tenant-specific MinIO key
isUploaded Boolean @default(false)
migratedFromIconId String?
@@index([tenantId]) @@index([tenantId])
@@index([categoryId])
@@map("tenant_symbols") @@map("tenant_symbols")
} }
// ─── Symbol Templates (global read-only packages) ─────────
model SymbolTemplate {
id String @id @default(uuid())
packageId String // e.g. "feuerwehr-ch"
packageName String // e.g. "Feuerwehr Schweiz"
categoryName String // e.g. "Fahrzeuge"
name String
svgPath String // relative path in public/ or MinIO key
tags String[] @default([])
sortOrder Int @default(0)
@@index([packageId])
@@map("symbol_templates")
}
// ─── Tenant Categories (per-tenant, user-managed) ─────────
model TenantCategory {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
sortOrder Int @default(0)
icon String? // Optional emoji or Lucide icon name for UI
symbols TenantSymbol[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("tenant_categories")
}
// ─── Dictionary (Global + Tenant word library) ──────────── // ─── Dictionary (Global + Tenant word library) ────────────
model DictionaryEntry { model DictionaryEntry {

View File

@@ -44,11 +44,10 @@ async function main() {
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] }, patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
] ]
// Delete ALL old system icons (regardless of fileKey pattern) // NOTE: We intentionally do NOT delete old system icons here.
const deleted = await prisma.iconAsset.deleteMany({ // TenantSymbol rows reference IconAsset.id via foreign key.
where: { isSystem: true }, // Deleting would either break references (tenant symbols become 404s)
}) // or cascade-delete tenant symbols. Instead we upsert by fileKey.
console.log(`🗑️ ${deleted.count} old system icons removed`)
// Upsert global categories (preserves tenant categories) // Upsert global categories (preserves tenant categories)
const catMap = {} const catMap = {}
@@ -90,6 +89,7 @@ async function main() {
} }
let created = 0 let created = 0
let updated = 0
for (const file of svgFiles) { for (const file of svgFiles) {
let name = file.replace('.svg', '') let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '') name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
@@ -99,7 +99,21 @@ async function main() {
const category = findCategory(file) const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } }) const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) { if (existing) {
await prisma.iconAsset.update({
where: { id: existing.id },
data: {
name,
categoryId: category.id,
mimeType: 'image/svg+xml',
isSystem: true,
isActive: true,
width: 48,
height: 48,
},
})
updated++
} else {
await prisma.iconAsset.create({ await prisma.iconAsset.create({
data: { data: {
name, name,
@@ -115,7 +129,7 @@ async function main() {
} }
} }
console.log(`✅ FKS Signaturen: ${created} new SVG icons created (${svgFiles.length} total)`) console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total)`)
} }
main() main()

View File

@@ -0,0 +1,123 @@
/**
* Seed-Skript: Erstellt SymbolTemplate-Einträge aus bestehenden IconAsset (isSystem=true)
* oder aus dem Dateisystem (public/signaturen/*.svg).
*
* Ausführen:
* npx ts-node prisma/seed-symbol-templates.ts
* oder als Teil des Deployments via npx prisma db seed
*
* Idempotent: bereits existierende Templates werden übersprungen.
*/
import { PrismaClient } from '@prisma/client'
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
const prisma = new PrismaClient()
const SIGNATUREN_DIR = join(process.cwd(), 'public', 'signaturen')
const PACKAGE_ID = 'feuerwehr-ch'
const PACKAGE_NAME = 'Feuerwehr Schweiz'
/**
* Versucht Kategorie-Namen aus bestehenden IconAsset / IconCategory abzuleiten.
* Fallback: "Sonstiges"
*/
async function getCategoryMapping(): Promise<Map<string, string>> {
const mapping = new Map<string, string>()
const iconAssets = await (prisma as any).iconAsset.findMany({
where: { isSystem: true },
include: { category: { select: { name: true } } },
})
for (const asset of iconAssets) {
const fileName = asset.fileKey.replace(/^signaturen\//, '').replace(/\.svg$/i, '')
const categoryName = asset.category?.name || 'Sonstiges'
mapping.set(fileName.toLowerCase(), categoryName)
}
return mapping
}
/**
* Liest alle .svg Dateien aus public/signaturen/
*/
function getSvgFiles(dir: string): string[] {
const files: string[] = []
try {
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && entry.endsWith('.svg')) {
files.push(entry)
}
}
} catch (err) {
console.warn('Could not read signaturen directory:', (err as Error).message)
}
return files.sort()
}
async function main() {
console.log('🌱 Seeding SymbolTemplate...')
const categoryMapping = await getCategoryMapping()
const svgFiles = getSvgFiles(SIGNATUREN_DIR)
console.log(`Found ${svgFiles.length} SVG files in public/signaturen/`)
console.log(`IconAsset mapping covers ${categoryMapping.size} entries`)
let created = 0
let skipped = 0
for (let i = 0; i < svgFiles.length; i++) {
const fileName = svgFiles[i]
const nameWithoutExt = fileName.replace(/\.svg$/i, '')
const displayName = nameWithoutExt
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
const fileKey = `signaturen/${fileName}`
// Check if already exists
const existing = await (prisma as any).symbolTemplate.findFirst({
where: { svgPath: fileKey },
})
if (existing) {
skipped++
continue
}
const categoryName =
categoryMapping.get(nameWithoutExt.toLowerCase()) || 'Sonstiges'
await (prisma as any).symbolTemplate.create({
data: {
packageId: PACKAGE_ID,
packageName: PACKAGE_NAME,
categoryName,
name: displayName,
svgPath: fileKey,
tags: [nameWithoutExt.toLowerCase(), categoryName.toLowerCase()],
sortOrder: i,
},
})
created++
}
console.log(`✅ Done: ${created} created, ${skipped} skipped`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -110,11 +110,10 @@ async function main() {
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] }, patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
] ]
// Delete ALL old system icons (regardless of fileKey pattern) // NOTE: We intentionally do NOT delete old system icons here.
const deletedIcons = await prisma.iconAsset.deleteMany({ // TenantSymbol rows reference IconAsset.id via foreign key.
where: { isSystem: true }, // Deleting would either break references (tenant symbols become 404s)
}) // or cascade-delete tenant symbols. Instead we upsert by fileKey.
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
// Clean up empty global categories // Clean up empty global categories
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } }) const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } })
@@ -163,6 +162,7 @@ async function main() {
} }
let created = 0 let created = 0
let updated = 0
for (const file of svgFiles) { for (const file of svgFiles) {
// Clean name: remove .svg, remove _de/_DE suffix // Clean name: remove .svg, remove _de/_DE suffix
let name = file.replace('.svg', '') let name = file.replace('.svg', '')
@@ -173,7 +173,21 @@ async function main() {
const category = findCategory(file) const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } }) const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) { if (existing) {
await prisma.iconAsset.update({
where: { id: existing.id },
data: {
name,
categoryId: category.id,
mimeType: 'image/svg+xml',
isSystem: true,
isActive: true,
width: 48,
height: 48,
},
})
updated++
} else {
await prisma.iconAsset.create({ await prisma.iconAsset.create({
data: { data: {
name, name,
@@ -189,7 +203,7 @@ async function main() {
} }
} }
console.log(`✅ FKS Signaturen: ${created} new icons created (${svgFiles.length} total SVGs)`) console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total SVGs)`)
// Create a demo project // Create a demo project
const demoProject = await prisma.project.upsert({ const demoProject = await prisma.project.upsert({

BIN
public/Pepe_Avatar.mp4 Normal file

Binary file not shown.

BIN
remote.txt Normal file

Binary file not shown.

View File

@@ -50,6 +50,7 @@ export default function AppPage() {
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([]) const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
const [isAuditOpen, setIsAuditOpen] = useState(false) const [isAuditOpen, setIsAuditOpen] = useState(false)
const [presentationLocked, setPresentationLocked] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('') const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
@@ -375,7 +376,7 @@ export default function AppPage() {
const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false
// User can only edit if they have the role AND they hold the editing lock (or no one is editing) // User can only edit if they have the role AND they hold the editing lock (or no one is editing)
const canEdit = roleCanEdit && (isEditingByMe || !editingBy) const canEdit = !presentationLocked && roleCanEdit && (isEditingByMe || !editingBy)
const isReadOnly = !!editingBy && !isEditingByMe const isReadOnly = !!editingBy && !isEditingByMe
// Auto-save: localStorage persistence + debounced API save + beacon on unload // Auto-save: localStorage persistence + debounced API save + beacon on unload
@@ -806,6 +807,14 @@ export default function AppPage() {
userRole={user?.role} userRole={user?.role}
onLogout={logout} onLogout={logout}
onStartTour={() => { resetOnboardingTour(); setShowTour(true) }} onStartTour={() => { resetOnboardingTour(); setShowTour(true) }}
presentationLocked={presentationLocked}
onTogglePresentationLock={() => {
const next = !presentationLocked
setPresentationLocked(next)
if (next && isEditingByMe) {
handleStopEditing()
}
}}
/> />
{/* Offline banner */} {/* Offline banner */}

View File

@@ -1,30 +1,16 @@
'use client'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/logo' import { Logo } from '@/components/ui/logo'
import { useAuth } from '@/components/providers/auth-provider' import { NavAuthButtons } from '@/components/landing/nav-auth-buttons'
import { useRouter } from 'next/navigation' import { ContactForm } from '@/components/landing/contact-form'
import { useEffect, useState } from 'react'
import { import {
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send, Check, ArrowRight, Lock, ChevronRight, MessageSquare,
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle, Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser, MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
} from 'lucide-react' } from 'lucide-react'
export default function LandingPage() { export default function LandingPage() {
const { user, loading } = useAuth()
const router = useRouter()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Laden...</div>
</div>
)
}
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
@@ -40,11 +26,6 @@ export default function LandingPage() {
priceCurrency: 'CHF', priceCurrency: 'CHF',
description: 'Kostenlos für Schweizer Feuerwehren', description: 'Kostenlos für Schweizer Feuerwehren',
}, },
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '12',
},
author: { author: {
'@type': 'Organization', '@type': 'Organization',
name: 'Lageplan.ch', name: 'Lageplan.ch',
@@ -62,12 +43,79 @@ export default function LandingPage() {
], ],
} }
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'Was kostet Lageplan?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
},
},
{
'@type': 'Question',
name: 'Brauche ich eine Installation?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nein. Lageplan läuft komplett im Browser — auf Desktop, Tablet und Smartphone. Einfach registrieren und loslegen.',
},
},
{
'@type': 'Question',
name: 'Funktioniert es auf dem Tablet im Einsatz?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Die App ist für Touch-Bedienung optimiert und funktioniert auf allen modernen Tablets und Smartphones.',
},
},
{
'@type': 'Question',
name: 'Können mehrere Personen gleichzeitig arbeiten?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Über Echtzeit-Synchronisation (WebSocket) können mehrere Benutzer gleichzeitig am selben Lageplan zeichnen und das Journal führen.',
},
},
{
'@type': 'Question',
name: 'Wo werden meine Daten gespeichert?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
},
},
{
'@type': 'Question',
name: 'Welche Symbole sind verfügbar?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
},
},
{
'@type': 'Question',
name: 'Kann ich Lagepläne exportieren?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Lagepläne können als PNG oder PDF exportiert werden — inklusive Metadaten, Datum und Einsatzinformationen.',
},
},
],
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<main> <main>
{/* Navigation */} {/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100"> <nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
@@ -84,24 +132,7 @@ export default function LandingPage() {
<a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a> <a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user ? ( <NavAuthButtons />
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -564,39 +595,6 @@ function SupportSection() {
} }
function ContactSection() { function ContactSection() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
return ( return (
<section id="contact" className="py-20 px-4"> <section id="contact" className="py-20 px-4">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@@ -607,67 +605,7 @@ function ContactSection() {
Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht. Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht.
</p> </p>
</div> </div>
<ContactForm />
{sent ? (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)}
</div> </div>
</section> </section>
) )

View File

@@ -23,10 +23,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.8, priority: 0.8,
}, },
{ {
url: `${baseUrl}/impressum`, url: `${baseUrl}/demo`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.7,
}, },
{ {
url: `${baseUrl}/spenden`, url: `${baseUrl}/spenden`,
@@ -34,5 +34,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.5, priority: 0.5,
}, },
{
url: `${baseUrl}/datenschutz`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${baseUrl}/impressum`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
] ]
} }

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Send, Check } from 'lucide-react'
export function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
if (sent) {
return (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/auth-provider'
export function NavAuthButtons() {
const { user, loading } = useAuth()
if (loading) {
return <div className="w-24 h-9" /> // placeholder to avoid layout shift
}
if (user) {
return (
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
)
}
return (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)
}

View File

@@ -39,6 +39,8 @@ import {
Shield, Shield,
MapPin, MapPin,
HelpCircle, HelpCircle,
Lock,
Unlock,
} from 'lucide-react' } from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/types' import type { Project, DrawFeature } from '@/types'
@@ -66,6 +68,8 @@ interface TopbarProps {
userRole?: string userRole?: string
onLogout?: () => void onLogout?: () => void
onStartTour?: () => void onStartTour?: () => void
presentationLocked?: boolean
onTogglePresentationLock?: () => void
} }
export function Topbar({ export function Topbar({
@@ -89,6 +93,8 @@ export function Topbar({
userRole, userRole,
onLogout, onLogout,
onStartTour, onStartTour,
presentationLocked,
onTogglePresentationLock,
}: TopbarProps) { }: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false) const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false) const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
@@ -172,6 +178,16 @@ export function Topbar({
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span> <span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</Button> </Button>
<Button
variant={presentationLocked ? 'default' : 'outline'}
className={`h-9 md:h-10 px-2 md:px-3 text-sm ${presentationLocked ? 'bg-amber-600 hover:bg-amber-700 text-white border-amber-600' : ''}`}
onClick={onTogglePresentationLock}
title={presentationLocked ? 'Präsentationsmodus deaktivieren' : 'Präsentationsmodus aktivieren'}
>
{presentationLocked ? <Lock className="w-5 h-5 md:mr-1" /> : <Unlock className="w-5 h-5 md:mr-1" />}
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü"> <Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">

View File

@@ -728,6 +728,46 @@ export function MapView({
// Expose map instance to parent for export // Expose map instance to parent for export
if (externalMapRef) externalMapRef.current = map.current if (externalMapRef) externalMapRef.current = map.current
// --- WebGL context loss recovery ---
// When the browser reclaims GPU memory (background tab, memory pressure),
// the WebGL context is lost and tiles go black. This recovers automatically.
const canvas = map.current.getCanvas()
canvas.addEventListener('webglcontextlost', (e) => {
console.warn('[Map] WebGL context lost — will restore when possible')
e.preventDefault() // allows context to be restored
})
canvas.addEventListener('webglcontextrestored', () => {
console.info('[Map] WebGL context restored — reloading map style')
const m = map.current
if (m) {
// Force full tile reload by re-setting the style
const style = m.getStyle()
if (style) {
m.setStyle(style)
}
}
})
// --- Page visibility recovery ---
// When user switches back to this tab after a while, tiles may be stale/black.
// Force a resize + tile re-request on visibility change.
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && map.current) {
// Small delay to let browser finish tab switch
setTimeout(() => {
if (!map.current) return
map.current.resize()
// Nudge the map to force tile re-requests
const center = map.current.getCenter()
map.current.setCenter(center)
}, 100)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Store cleanup reference
const cleanupVisibility = () => document.removeEventListener('visibilitychange', handleVisibilityChange)
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right') map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left') map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
@@ -1286,6 +1326,7 @@ export function MapView({
}) })
return () => { return () => {
cleanupVisibility()
map.current?.remove() map.current?.remove()
map.current = null map.current = null
} }
@@ -1421,26 +1462,27 @@ export function MapView({
const lineCoords = f.geometry.coordinates as number[][] const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) return if (lineCoords.length < 2) return
// Get last two points to calculate arrow direction using screen-projected coords // Geographic bearing from p1 to p2 (works for short distances)
const p1 = lineCoords[lineCoords.length - 2] const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1] const p2 = lineCoords[lineCoords.length - 1]
const px1 = map.current.project(p1 as [number, number]) const dLng = p2[0] - p1[0]
const px2 = map.current.project(p2 as [number, number]) const dLat = p2[1] - p1[1]
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90 // atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI)
const color = (f.properties.color as string) || '#000000' const color = (f.properties.color as string) || '#000000'
const arrowEl = document.createElement('div') const arrowEl = document.createElement('div')
arrowEl.style.cssText = ` arrowEl.style.cssText = `
width: 0; height: 0; width: 0; height: 0;
border-left: 10px solid transparent; border-left: 12px solid transparent;
border-right: 10px solid transparent; border-right: 12px solid transparent;
border-bottom: 20px solid ${color}; border-bottom: 24px solid ${color};
transform: rotate(${screenAngle}deg); transform: rotate(${geoBearing}deg);
transform-origin: center center; transform-origin: center center;
pointer-events: none; pointer-events: none;
` `
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(p2 as [number, number]) .setLngLat(p2 as [number, number])
.addTo(map.current) .addTo(map.current)
markersRef.current.push(marker) markersRef.current.push(marker)
@@ -1545,7 +1587,7 @@ export function MapView({
}) })
} }
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'map' })
.setLngLat(midpoint) .setLngLat(midpoint)
.addTo(map.current) .addTo(map.current)
@@ -1664,7 +1706,7 @@ export function MapView({
} }
try { try {
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1730,7 +1772,7 @@ export function MapView({
el.textContent = (f.properties.text as string) || '' el.textContent = (f.properties.text as string) || ''
wrapper.appendChild(el) wrapper.appendChild(el)
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1892,18 +1934,27 @@ export function MapView({
} }
}, [drawMode, deselectSymbol]) }, [drawMode, deselectSymbol])
// ESC to cancel drawing, DEL to delete selected symbol // ESC to cancel drawing, DEL to delete selected symbol/line/polygon
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// DEL / Backspace → delete selected symbol // DEL / Backspace → delete selected symbol or line/polygon
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
const tag = (e.target as HTMLElement)?.tagName const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// Delete selected symbol/text
if (selectedSymbolRef.current) { if (selectedSymbolRef.current) {
e.preventDefault() e.preventDefault()
deleteSelectedSymbol() deleteSelectedSymbol()
return return
} }
// Delete selected line/polygon/arrow (vertex-editing selection)
if (selectedLineIdRef.current) {
e.preventDefault()
const updated = featuresRef.current.filter(f => f.id !== selectedLineIdRef.current)
onFeaturesChangeRef.current(updated)
showVertexMarkersRef.current(null)
return
}
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// In measure mode: finalize (keep line + labels), just stop adding // In measure mode: finalize (keep line + labels), just stop adding

View File

@@ -21,7 +21,7 @@ interface ToolStore {
export const useToolStore = create<ToolStore>((set) => ({ export const useToolStore = create<ToolStore>((set) => ({
activeTool: 'select', activeTool: 'select',
activeColor: '#ff0000', // Default Rot activeColor: '#000000', // Default Schwarz
lineType: 'solid', lineType: 'solid',
lineWidth: 3, lineWidth: 3,
selectedFeatureId: null, selectedFeatureId: null,