Compare commits
12 Commits
v1.3.1
...
8207366362
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8207366362 | ||
|
|
29217e883b | ||
|
|
0f635033c2 | ||
|
|
805559efc3 | ||
|
|
5d46200905 | ||
|
|
5c353a0da8 | ||
|
|
ba6f095dc0 | ||
|
|
362a7e4666 | ||
|
|
63a57dcb7c | ||
|
|
62a5a56dea | ||
|
|
eb8566423f | ||
|
|
1f508bca74 |
13
.env.docker
Normal file
13
.env.docker
Normal 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
|
||||
@@ -27,3 +27,8 @@ MINIO_PUBLIC_URL=http://localhost:9002
|
||||
# Web App
|
||||
WEB_PORT=3000
|
||||
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
|
||||
|
||||
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal 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_PASSWORD }}
|
||||
|
||||
- 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,4 +58,3 @@ Reglement_*/
|
||||
|
||||
# Stack env (contains secrets)
|
||||
stack.env
|
||||
.env.docker
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -26,34 +26,37 @@ RUN npm run build
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
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
|
||||
WORKDIR /app
|
||||
# Fast: only chown the /app directory itself, not recursively
|
||||
RUN chown nextjs:nodejs /app
|
||||
|
||||
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/public ./public
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
|
||||
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/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
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
186
deploy/README.md
186
deploy/README.md
@@ -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 |
|
||||
|-------|-------------|
|
||||
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) |
|
||||
| `portainer-stack.yml` | Stack YAML für Portainer |
|
||||
| `.env.example` | Environment Variables Vorlage |
|
||||
- Gitea läuft mit Container Registry aktiviert
|
||||
- Gitea Actions Runner ist registriert (`deploy/docker-compose.runner.yml`)
|
||||
- Portainer Stack ist deployed mit korrekten Environment-Variablen
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1: Image auf Server laden
|
||||
## Schritt 1: Gitea Container Registry aktivieren
|
||||
|
||||
```bash
|
||||
# Kopieren
|
||||
scp lageplan-web-v1.0.0.tar user@server:/tmp/
|
||||
In Gitea:
|
||||
1. **Admin-Konsole** → **Konfiguration** → **Pakete**
|
||||
2. **Container Registry** auf `Aktiviert` setzen
|
||||
3. Speichern
|
||||
|
||||
# Auf dem Server laden
|
||||
docker load -i /tmp/lageplan-web-v1.0.0.tar
|
||||
Oder direkt in der `app.ini`:
|
||||
```ini
|
||||
[packages]
|
||||
ENABLED = true
|
||||
|
||||
# Prüfen
|
||||
docker images | grep lageplan
|
||||
[package.container_registry]
|
||||
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`
|
||||
2. **Name**: `lageplan`
|
||||
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen
|
||||
4. **Environment variables** setzen:
|
||||
3. **Build method**: `Repository`
|
||||
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 |
|
||||
|----------|------|
|
||||
| `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)* |
|
||||
### Environment Variables setzen:
|
||||
|
||||
> **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
|
||||
npx prisma db push
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
Oder per SSH:
|
||||
|
||||
```bash
|
||||
docker exec -it lageplan-web-1 npx prisma db push
|
||||
docker exec -it lageplan-web-1 npx prisma db seed
|
||||
```
|
||||
1. Gitea Repo → **Einstellungen** → **Webhooks** → **Neuer Webhook** → `Gitea`
|
||||
2. **Ziel-URL**: Die kopierte Portainer Webhook URL
|
||||
3. **HTTP-Methode**: `POST`
|
||||
4. **Trigger**: Nur `Push events` (oder auch `Branch filter: main`)
|
||||
5. **Webhook aktivieren** → Hinzufügen
|
||||
|
||||
---
|
||||
|
||||
## Schritt 4: Zugriff
|
||||
## Schritt 6: Erstes Deployment testen
|
||||
|
||||
- **Web App**: `http://SERVER_IP:3000`
|
||||
- **Login**: `admin@lageplan.local` / `admin123`
|
||||
1. Lokal einen Push auf `main` machen:
|
||||
```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
|
||||
# Lokal: neues Image bauen + exportieren
|
||||
docker compose build web
|
||||
docker tag lageplan-web:latest lageplan-web:v1.1.0
|
||||
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
|
||||
# Auf dem Portainer-Host
|
||||
docker pull git.purepixel.ch/adminpepe/lageplan:latest
|
||||
docker compose -f docker-compose.portainer.yml up -d web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
23
deploy/docker-compose.runner.yml
Normal file
23
deploy/docker-compose.runner.yml
Normal 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:
|
||||
@@ -7,3 +7,8 @@ MINIO_BUCKET=lageplan-icons
|
||||
WEB_PORT=3000
|
||||
NEXTAUTH_URL=http://SERVER_IP:3000
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
##############################################
|
||||
# Gitea — Lightweight Git Server
|
||||
# Gitea — Lightweight Git Server + Container Registry
|
||||
#
|
||||
# Verwendung in Portainer:
|
||||
# 1. Stacks → Add Stack → "Gitea"
|
||||
@@ -12,6 +12,10 @@
|
||||
# 3. Repository "lageplan" erstellen
|
||||
# 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.
|
||||
##############################################
|
||||
|
||||
@@ -27,6 +31,9 @@ services:
|
||||
- GITEA__server__ROOT_URL=https://git.purepixel.ch
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
- GITEA__server__LFS_START_SERVER=true
|
||||
# Container Registry aktivieren
|
||||
- GITEA__packages__ENABLED=true
|
||||
- GITEA__package__container_registry__ENABLED=true
|
||||
volumes:
|
||||
- gitea_data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
@@ -43,4 +50,3 @@ volumes:
|
||||
networks:
|
||||
lageplan_lageplan-net:
|
||||
external: true
|
||||
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
##############################################
|
||||
# Lageplan — Portainer Stack Configuration
|
||||
#
|
||||
# Verwendung in Portainer:
|
||||
# 1. Stacks → Add Stack
|
||||
# 2. "Upload" oder diesen Inhalt einfügen
|
||||
# 3. Environment-Variablen setzen (siehe unten)
|
||||
# 4. Deploy
|
||||
# Lageplan — Portainer Stack (Watchtower Auto-Update)
|
||||
#
|
||||
# Setup in Portainer:
|
||||
# 1. Stacks → Add Stack → "Repository"
|
||||
# 2. Git-URL: https://git.purepixel.ch/adminpepe/Lageplan.git
|
||||
# 3. Compose-Pfad: docker-compose.portainer.yml
|
||||
# 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:
|
||||
# POSTGRES_USER (default: lageplan)
|
||||
# POSTGRES_PASSWORD (ÄNDERN!)
|
||||
# POSTGRES_DB (default: lageplan)
|
||||
# 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_PASSWORD (ÄNDERN!)
|
||||
# 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:
|
||||
@@ -76,8 +83,9 @@ services:
|
||||
- lageplan
|
||||
|
||||
# ─── Lageplan Web App ──────────────────────
|
||||
# Image kommt aus Gitea Container Registry (gebaut via Gitea Actions)
|
||||
web:
|
||||
image: 192.168.1.183:3100/adminpepe/lageplan:latest
|
||||
image: git.purepixel.ch/adminpepe/lageplan:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
|
||||
@@ -99,6 +107,25 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- 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:
|
||||
postgres_data:
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lageplan",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.4",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
BIN
remote.txt
Normal file
BIN
remote.txt
Normal file
Binary file not shown.
208
src/app/page.tsx
208
src/app/page.tsx
@@ -1,30 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
import { useAuth } from '@/components/providers/auth-provider'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavAuthButtons } from '@/components/landing/nav-auth-buttons'
|
||||
import { ContactForm } from '@/components/landing/contact-form'
|
||||
import {
|
||||
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
|
||||
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send,
|
||||
Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
|
||||
Check, ArrowRight, Lock, ChevronRight, MessageSquare,
|
||||
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
|
||||
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
|
||||
} from 'lucide-react'
|
||||
|
||||
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 = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
@@ -40,11 +26,6 @@ export default function LandingPage() {
|
||||
priceCurrency: 'CHF',
|
||||
description: 'Kostenlos für Schweizer Feuerwehren',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
ratingCount: '12',
|
||||
},
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
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 (
|
||||
<div className="min-h-screen bg-white">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
<main>
|
||||
{/* Navigation */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<NavAuthButtons />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,39 +595,6 @@ function SupportSection() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<section id="contact" className="py-20 px-4">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -23,10 +23,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/impressum`,
|
||||
url: `${baseUrl}/demo`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/spenden`,
|
||||
@@ -34,5 +34,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'monthly',
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
105
src/components/landing/contact-form.tsx
Normal file
105
src/components/landing/contact-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/landing/nav-auth-buttons.tsx
Normal file
36
src/components/landing/nav-auth-buttons.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -728,6 +728,46 @@ export function MapView({
|
||||
// Expose map instance to parent for export
|
||||
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.ScaleControl(), 'bottom-left')
|
||||
|
||||
@@ -1286,6 +1326,7 @@ export function MapView({
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupVisibility()
|
||||
map.current?.remove()
|
||||
map.current = null
|
||||
}
|
||||
@@ -1421,26 +1462,27 @@ export function MapView({
|
||||
const lineCoords = f.geometry.coordinates as number[][]
|
||||
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 p2 = lineCoords[lineCoords.length - 1]
|
||||
const px1 = map.current.project(p1 as [number, number])
|
||||
const px2 = map.current.project(p2 as [number, number])
|
||||
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90
|
||||
const dLng = p2[0] - p1[0]
|
||||
const dLat = p2[1] - p1[1]
|
||||
// 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 arrowEl = document.createElement('div')
|
||||
arrowEl.style.cssText = `
|
||||
width: 0; height: 0;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 20px solid ${color};
|
||||
transform: rotate(${screenAngle}deg);
|
||||
border-left: 12px solid transparent;
|
||||
border-right: 12px solid transparent;
|
||||
border-bottom: 24px solid ${color};
|
||||
transform: rotate(${geoBearing}deg);
|
||||
transform-origin: center center;
|
||||
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])
|
||||
.addTo(map.current)
|
||||
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)
|
||||
.addTo(map.current)
|
||||
|
||||
@@ -1664,7 +1706,7 @@ export function MapView({
|
||||
}
|
||||
|
||||
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)
|
||||
.addTo(map.current)
|
||||
|
||||
@@ -1730,7 +1772,7 @@ export function MapView({
|
||||
el.textContent = (f.properties.text as string) || ''
|
||||
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)
|
||||
.addTo(map.current)
|
||||
|
||||
@@ -1892,18 +1934,27 @@ export function MapView({
|
||||
}
|
||||
}, [drawMode, deselectSymbol])
|
||||
|
||||
// ESC to cancel drawing, DEL to delete selected symbol
|
||||
// ESC to cancel drawing, DEL to delete selected symbol/line/polygon
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// DEL / Backspace → delete selected symbol
|
||||
// DEL / Backspace → delete selected symbol or line/polygon
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
|
||||
// Delete selected symbol/text
|
||||
if (selectedSymbolRef.current) {
|
||||
e.preventDefault()
|
||||
deleteSelectedSymbol()
|
||||
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') {
|
||||
// In measure mode: finalize (keep line + labels), just stop adding
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ToolStore {
|
||||
|
||||
export const useToolStore = create<ToolStore>((set) => ({
|
||||
activeTool: 'select',
|
||||
activeColor: '#ff0000', // Default Rot
|
||||
activeColor: '#000000', // Default Schwarz
|
||||
lineType: 'solid',
|
||||
lineWidth: 3,
|
||||
selectedFeatureId: null,
|
||||
|
||||
Reference in New Issue
Block a user