Compare commits
16 Commits
v1.3.0
...
165109fc65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165109fc65 | ||
|
|
b6fbe38f60 | ||
|
|
053ae3729a | ||
|
|
8207366362 | ||
|
|
29217e883b | ||
|
|
0f635033c2 | ||
|
|
805559efc3 | ||
|
|
5d46200905 | ||
|
|
5c353a0da8 | ||
|
|
ba6f095dc0 | ||
|
|
362a7e4666 | ||
|
|
63a57dcb7c | ||
|
|
62a5a56dea | ||
|
|
eb8566423f | ||
|
|
1f508bca74 | ||
|
|
708bdf6be0 |
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 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
|
||||||
|
|||||||
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_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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,4 +58,3 @@ Reglement_*/
|
|||||||
|
|
||||||
# Stack env (contains secrets)
|
# Stack env (contains secrets)
|
||||||
stack.env
|
stack.env
|
||||||
.env.docker
|
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -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"
|
||||||
|
|||||||
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 |
|
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.2.2",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.2.2",
|
"version": "1.3.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.2.2",
|
"version": "1.3.4",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
BIN
remote.txt
Normal file
BIN
remote.txt
Normal file
Binary file not shown.
@@ -53,6 +53,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Building2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
||||||
@@ -62,6 +63,7 @@ import { SomaTab } from '@/components/admin/soma-tab'
|
|||||||
import { SuggestionsTab } from '@/components/admin/suggestions-tab'
|
import { SuggestionsTab } from '@/components/admin/suggestions-tab'
|
||||||
import { DictionaryTab } from '@/components/admin/dictionary-tab'
|
import { DictionaryTab } from '@/components/admin/dictionary-tab'
|
||||||
import { SymbolManager } from '@/components/admin/symbol-manager'
|
import { SymbolManager } from '@/components/admin/symbol-manager'
|
||||||
|
import { OrgTab } from '@/components/admin/org-tab'
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
interface IconCategory {
|
interface IconCategory {
|
||||||
@@ -133,7 +135,7 @@ export default function AdminPage() {
|
|||||||
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'users')
|
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'org')
|
||||||
|
|
||||||
// Category Dialog
|
// Category Dialog
|
||||||
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false)
|
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false)
|
||||||
@@ -571,10 +573,22 @@ export default function AdminPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
) : user?.role === 'TENANT_ADMIN' ? (
|
) : user?.role === 'TENANT_ADMIN' ? (
|
||||||
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
|
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
|
||||||
|
<TabsTrigger value="org" className="gap-2">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Organisation
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="users" className="gap-2">
|
<TabsTrigger value="users" className="gap-2">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
Benutzer
|
Benutzer
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="icons" className="gap-2">
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
Symbole
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="soma" className="gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
SOMA
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="suggestions" className="gap-2">
|
<TabsTrigger value="suggestions" className="gap-2">
|
||||||
<ClipboardList className="w-4 h-4" />
|
<ClipboardList className="w-4 h-4" />
|
||||||
Wörterliste
|
Wörterliste
|
||||||
@@ -587,21 +601,16 @@ export default function AdminPage() {
|
|||||||
<Heart className="w-4 h-4" />
|
<Heart className="w-4 h-4" />
|
||||||
Spenden
|
Spenden
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="icons" className="gap-2">
|
|
||||||
<Image className="w-4 h-4" />
|
|
||||||
Symbole
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="categories" className="gap-2">
|
|
||||||
<Layers className="w-4 h-4" />
|
|
||||||
Kategorien
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="soma" className="gap-2">
|
|
||||||
<AlertTriangle className="w-4 h-4" />
|
|
||||||
SOMA
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* ===== ORGANISATION TAB (TENANT_ADMIN) ===== */}
|
||||||
|
{user?.role === 'TENANT_ADMIN' && (
|
||||||
|
<TabsContent value="org" className="space-y-4">
|
||||||
|
<OrgTab tenantId={tenant?.id} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===== ICONS TAB ===== */}
|
{/* ===== ICONS TAB ===== */}
|
||||||
<TabsContent value="icons" className="space-y-4">
|
<TabsContent value="icons" className="space-y-4">
|
||||||
{user?.role === 'TENANT_ADMIN' ? (
|
{user?.role === 'TENANT_ADMIN' ? (
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
contactEmail: true,
|
||||||
|
contactPhone: true,
|
||||||
|
address: true,
|
||||||
|
logoUrl: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
subscriptionStatus: true,
|
subscriptionStatus: true,
|
||||||
contactEmail: true,
|
|
||||||
privacyAccepted: true,
|
privacyAccepted: true,
|
||||||
privacyAcceptedAt: true,
|
privacyAcceptedAt: true,
|
||||||
adminAccessAccepted: true,
|
adminAccessAccepted: true,
|
||||||
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
if (user.role !== 'TENANT_ADMIN') return NextResponse.json({ error: 'Nur Admin' }, { status: 403 })
|
||||||
|
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { name, description, contactEmail, contactPhone, address } = body
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await (prisma as any).tenant.update({
|
||||||
|
where: { id: user.tenantId },
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description || null,
|
||||||
|
contactEmail: contactEmail || null,
|
||||||
|
contactPhone: contactPhone || null,
|
||||||
|
address: address || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ tenant: updated })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Tenant Info PATCH] Error:', error?.message)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
77
src/app/api/tenant/logo/route.ts
Normal file
77
src/app/api/tenant/logo/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { uploadFile, deleteFile } from '@/lib/minio'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||||
|
}
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('logo') as File
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
const ext = file.name.split('.').pop() || 'png'
|
||||||
|
const fileKey = `logos/tenant-${user.tenantId}.${ext}`
|
||||||
|
|
||||||
|
await uploadFile(fileKey, buffer, file.type)
|
||||||
|
|
||||||
|
const logoServeUrl = `/api/admin/tenants/${user.tenantId}/logo/serve`
|
||||||
|
await (prisma as any).tenant.update({
|
||||||
|
where: { id: user.tenantId },
|
||||||
|
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ logoUrl: logoServeUrl })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tenant logo upload error:', error)
|
||||||
|
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||||
|
}
|
||||||
|
if (!user.tenantId) {
|
||||||
|
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await (prisma as any).tenant.findUnique({ where: { id: user.tenantId } })
|
||||||
|
if (tenant?.logoFileKey) {
|
||||||
|
try {
|
||||||
|
await deleteFile(tenant.logoFileKey)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await (prisma as any).tenant.update({
|
||||||
|
where: { id: user.tenantId },
|
||||||
|
data: { logoUrl: null, logoFileKey: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tenant logo delete error:', error)
|
||||||
|
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -862,7 +862,7 @@ export default function AppPage() {
|
|||||||
<span>Niemand bearbeitet gerade</span>
|
<span>Niemand bearbeitet gerade</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div data-tour="edit-toggle" className="flex items-center gap-2">
|
||||||
{roleCanEdit && !isEditingByMe && !isReadOnly && (
|
{roleCanEdit && !isEditingByMe && !isReadOnly && (
|
||||||
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
|
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
|
||||||
<Lock className="w-3.5 h-3.5 mr-1" />
|
<Lock className="w-3.5 h-3.5 mr-1" />
|
||||||
|
|||||||
208
src/app/page.tsx
208
src/app/page.tsx
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 5. Eingesetzte Mittel */}
|
{/* 5. SOMA Checkliste */}
|
||||||
|
{Array.isArray(d.somaItems) && d.somaItems.length > 0 && (
|
||||||
|
<Section num="5" title="SOMA Checkliste">
|
||||||
|
<div className="border rounded">
|
||||||
|
<div className="grid grid-cols-[24px_24px_1fr_60px] gap-0 text-[7pt] font-semibold uppercase tracking-wider bg-gray-900 text-white p-1.5">
|
||||||
|
<span className="text-center">Best.</span>
|
||||||
|
<span className="text-center">OK</span>
|
||||||
|
<span>Punkt</span>
|
||||||
|
<span className="text-right">Zeit</span>
|
||||||
|
</div>
|
||||||
|
{d.somaItems.map((s: any, i: number) => (
|
||||||
|
<div key={i} className={`grid grid-cols-[24px_24px_1fr_60px] gap-0 p-1.5 border-b border-gray-100 text-[9pt] ${i % 2 === 1 ? 'bg-gray-50' : ''}`}>
|
||||||
|
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
|
||||||
|
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
|
||||||
|
<span className="font-medium">{s.label}</span>
|
||||||
|
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Pendenzen */}
|
||||||
|
{Array.isArray(d.pendenzenItems) && d.pendenzenItems.length > 0 && (
|
||||||
|
<Section num="6" title="Pendenzen">
|
||||||
|
<table className="w-full border-collapse border rounded text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-white">
|
||||||
|
<th className="p-1.5 text-center font-semibold uppercase tracking-wider text-[7pt] w-8">✓</th>
|
||||||
|
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Aufgabe</th>
|
||||||
|
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-24">Wer</th>
|
||||||
|
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-32">Wann / Wie</th>
|
||||||
|
<th className="p-1.5 text-right font-semibold uppercase tracking-wider text-[7pt] w-16">Erledigt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{d.pendenzenItems.map((p: any, i: number) => (
|
||||||
|
<tr key={i} className={`${i % 2 === 1 ? 'bg-gray-50' : ''} ${p.done ? 'text-gray-400' : ''}`}>
|
||||||
|
<td className="p-1.5 border-b border-gray-100 text-center font-bold">{p.done ? '✓' : '○'}</td>
|
||||||
|
<td className={`p-1.5 border-b border-gray-100 ${p.done ? 'line-through' : ''}`}>{p.what}</td>
|
||||||
|
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.who || '—'}</td>
|
||||||
|
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.whenHow || '—'}</td>
|
||||||
|
<td className="p-1.5 border-b border-gray-100 text-right font-mono text-[8pt]">{p.doneAt || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 7. Eingesetzte Mittel */}
|
||||||
{d.fahrzeuge?.length > 0 && (
|
{d.fahrzeuge?.length > 0 && (
|
||||||
<Section num="5" title="Eingesetzte Mittel">
|
<Section num="7" title="Eingesetzte Mittel">
|
||||||
<table className="w-full border-collapse border rounded text-xs">
|
<table className="w-full border-collapse border rounded text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-900 text-white">
|
<tr className="bg-gray-900 text-white">
|
||||||
@@ -238,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 6. Bemerkungen */}
|
{/* 8. Bemerkungen */}
|
||||||
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
|
<Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
|
||||||
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
|
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/components/admin/org-tab.tsx
Normal file
246
src/components/admin/org-tab.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import {
|
||||||
|
Building2, Upload, X, Loader2, Shield, Trash2, AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface OrgTabProps {
|
||||||
|
tenantId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrgTab({ tenantId }: OrgTabProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||||||
|
const [tenant, setTenant] = useState<any>(null)
|
||||||
|
|
||||||
|
// Editable fields
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [contactEmail, setContactEmail] = useState('')
|
||||||
|
const [contactPhone, setContactPhone] = useState('')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
|
||||||
|
const fetchTenant = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/info')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const t = data.tenant
|
||||||
|
if (t) {
|
||||||
|
setTenant(t)
|
||||||
|
setName(t.name || '')
|
||||||
|
setDescription(t.description || '')
|
||||||
|
setContactEmail(t.contactEmail || '')
|
||||||
|
setContactPhone(t.contactPhone || '')
|
||||||
|
setAddress(t.address || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load tenant info:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTenant()
|
||||||
|
}, [tenantId])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/info', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
contactEmail: contactEmail.trim() || null,
|
||||||
|
contactPhone: contactPhone.trim() || null,
|
||||||
|
address: address.trim() || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: 'Organisation aktualisiert' })
|
||||||
|
fetchTenant()
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
toast({ title: 'Fehler', description: err.error || 'Speichern fehlgeschlagen', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', description: 'Verbindungsfehler', variant: 'destructive' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files?.[0]) return
|
||||||
|
setUploadingLogo(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('logo', e.target.files[0])
|
||||||
|
const res = await fetch('/api/tenant/logo', { method: 'POST', body: formData })
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: 'Logo hochgeladen' })
|
||||||
|
fetchTenant()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
toast({ title: 'Fehler', description: data.error || 'Upload fehlgeschlagen', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', description: 'Upload fehlgeschlagen', variant: 'destructive' })
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogoDelete = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/logo', { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: 'Logo entfernt' })
|
||||||
|
fetchTenant()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-muted-foreground py-12">
|
||||||
|
Keine Organisation zugeordnet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="border rounded-lg p-5">
|
||||||
|
<h3 className="font-semibold text-base mb-3 flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Logo
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 h-20 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{tenant.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/tenant/logo/serve`}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="w-10 h-10 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="relative" disabled={uploadingLogo}>
|
||||||
|
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
{tenant.logoUrl ? 'Ändern' : 'Hochladen'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{tenant.logoUrl && (
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleLogoDelete}>
|
||||||
|
<X className="w-3.5 h-3.5 mr-1" /> Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">PNG, JPEG, SVG oder WebP, max. 2 MB. Wird auch im Rapport angezeigt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation Details */}
|
||||||
|
<div className="border rounded-lg p-5 space-y-4">
|
||||||
|
<h3 className="font-semibold text-base mb-1 flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Stammdaten
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Slug</Label>
|
||||||
|
<Input value={tenant.slug} disabled className="font-mono bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Beschreibung</Label>
|
||||||
|
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="z.B. Feuerwehr Musterstadt" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Kontakt E-Mail</Label>
|
||||||
|
<Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@feuerwehr.ch" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Kontakt Telefon</Label>
|
||||||
|
<Input value={contactPhone} onChange={e => setContactPhone(e.target.value)} placeholder="+41 ..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Adresse</Label>
|
||||||
|
<Input value={address} onChange={e => setAddress(e.target.value)} placeholder="Strasse, PLZ Ort" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info (read-only) */}
|
||||||
|
<div className="border rounded-lg p-5">
|
||||||
|
<h3 className="font-semibold text-base mb-3">Übersicht</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Plan:</span>
|
||||||
|
<span className="ml-2">{tenant.plan}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Status:</span>
|
||||||
|
<span className="ml-2">{tenant.subscriptionStatus}</span>
|
||||||
|
</div>
|
||||||
|
{tenant._count && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Benutzer:</span>
|
||||||
|
<span className="ml-2">{tenant._count.memberships}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Einsätze:</span>
|
||||||
|
<span className="ml-2">{tenant._count.projects}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { MapPin, Loader2, X } from 'lucide-react'
|
import { MapPin, Loader2, X } from 'lucide-react'
|
||||||
import type { Project } from '@/app/app/page'
|
import type { Project } from '@/types'
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
place_id: number
|
place_id: number
|
||||||
|
|||||||
@@ -461,8 +461,21 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
|
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
|
||||||
lageEintreffen: '',
|
lageEintreffen: '',
|
||||||
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
|
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
|
||||||
|
somaItems: checkItems.map(c => ({
|
||||||
|
label: c.label,
|
||||||
|
confirmed: c.confirmed,
|
||||||
|
ok: c.ok,
|
||||||
|
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
|
||||||
|
})),
|
||||||
|
pendenzenItems: pendenzen.map(p => ({
|
||||||
|
what: p.what,
|
||||||
|
who: p.who || '',
|
||||||
|
whenHow: p.whenHow || '',
|
||||||
|
done: p.done,
|
||||||
|
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
|
||||||
|
})),
|
||||||
fahrzeuge: [] as any[],
|
fahrzeuge: [] as any[],
|
||||||
bemerkungen: pendenzen.filter(p => !p.done).map(p => `PENDENT: ${p.what}${p.who ? ` (${p.who})` : ''}`).join('\n'),
|
bemerkungen: '',
|
||||||
einsatzleiter: einsatzleiter || '',
|
einsatzleiter: einsatzleiter || '',
|
||||||
rapporteur: journalfuehrer || '',
|
rapporteur: journalfuehrer || '',
|
||||||
reportNumber: '',
|
reportNumber: '',
|
||||||
|
|||||||
@@ -202,6 +202,39 @@ export function RapportDialog({
|
|||||||
) : <span className="text-muted-foreground">Keine Einträge</span>}
|
) : <span className="text-muted-foreground">Keine Einträge</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* SOMA Checklist (read-only, from journal) */}
|
||||||
|
{Array.isArray(rapportForm.somaItems) && rapportForm.somaItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">SOMA Checkliste</label>
|
||||||
|
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||||
|
{rapportForm.somaItems.map((s: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 py-0.5">
|
||||||
|
<span className={`inline-block w-4 text-center ${s.confirmed ? 'text-blue-600 font-bold' : 'text-muted-foreground'}`}>{s.confirmed ? '✓' : '—'}</span>
|
||||||
|
<span className={`inline-block w-4 text-center ${s.ok ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{s.ok ? '✓' : '—'}</span>
|
||||||
|
<span>{s.label}</span>
|
||||||
|
{s.confirmedAt && <span className="text-[10px] text-muted-foreground ml-auto">{s.confirmedAt}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Pendenzen (read-only, from journal) */}
|
||||||
|
{Array.isArray(rapportForm.pendenzenItems) && rapportForm.pendenzenItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Pendenzen</label>
|
||||||
|
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||||
|
{rapportForm.pendenzenItems.map((p: any, i: number) => (
|
||||||
|
<div key={i} className={`flex items-center gap-2 py-0.5 ${p.done ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
<span className={`inline-block w-4 text-center ${p.done ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{p.done ? '✓' : '○'}</span>
|
||||||
|
<span>{p.what}</span>
|
||||||
|
{p.who && <span className="text-muted-foreground text-xs">({p.who})</span>}
|
||||||
|
{p.whenHow && <span className="text-muted-foreground text-xs ml-1">— {p.whenHow}</span>}
|
||||||
|
{p.doneAt && <span className="text-[10px] text-green-600 ml-auto">{p.doneAt}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Bemerkungen */}
|
{/* Bemerkungen */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Ruler,
|
Ruler,
|
||||||
Eraser,
|
Eraser,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { DrawMode } from '@/app/app/page'
|
import type { DrawMode } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface LeftToolbarProps {
|
interface LeftToolbarProps {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
async function fetchIcons() {
|
async function fetchIcons() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/icons')
|
const res = await fetch('/api/icons', { cache: 'no-store' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const allCats: DisplayCategory[] = (data.categories || [])
|
const allCats: DisplayCategory[] = (data.categories || [])
|
||||||
@@ -154,7 +154,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchIcons()
|
fetchIcons()
|
||||||
}, [])
|
}, [tenantId])
|
||||||
|
|
||||||
const filteredCategories = categories.map((cat) => ({
|
const filteredCategories = categories.map((cat) => ({
|
||||||
...cat,
|
...cat,
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ import {
|
|||||||
ImagePlus,
|
ImagePlus,
|
||||||
Key,
|
Key,
|
||||||
Shield,
|
Shield,
|
||||||
Building2,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
} 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 '@/app/app/page'
|
import type { Project, DrawFeature } from '@/types'
|
||||||
import { formatDateTime } from '@/lib/utils'
|
import { formatDateTime } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/ui/logo'
|
import { Logo } from '@/components/ui/logo'
|
||||||
|
|
||||||
@@ -286,12 +285,6 @@ export function Topbar({
|
|||||||
<Key className="w-4 h-4 mr-2" />
|
<Key className="w-4 h-4 mr-2" />
|
||||||
Kennwort ändern
|
Kennwort ändern
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{userRole === 'TENANT_ADMIN' && (
|
|
||||||
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
|
|
||||||
<Building2 className="w-4 h-4 mr-2" />
|
|
||||||
Organisation
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
||||||
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
|
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
|
||||||
<Shield className="w-4 h-4 mr-2" />
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
|||||||
import { useDrop } from 'react-dnd'
|
import { useDrop } from 'react-dnd'
|
||||||
import Moveable from 'react-moveable'
|
import Moveable from 'react-moveable'
|
||||||
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
|
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
|
||||||
import type { Project, DrawFeature, DrawMode } from '@/app/app/page'
|
import type { Project, DrawFeature, DrawMode } from '@/types'
|
||||||
|
|
||||||
// Haversine distance between two [lng, lat] points in meters
|
// Haversine distance between two [lng, lat] points in meters
|
||||||
function haversineDistance(a: number[], b: number[]): number {
|
function haversineDistance(a: number[], b: number[]): number {
|
||||||
@@ -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,9 +1934,28 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
}, [drawMode, deselectSymbol])
|
}, [drawMode, deselectSymbol])
|
||||||
|
|
||||||
// ESC to cancel drawing / finalize measurement
|
// 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 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') {
|
if (e.key === 'Escape') {
|
||||||
// In measure mode: finalize (keep line + labels), just stop adding
|
// In measure mode: finalize (keep line + labels), just stop adding
|
||||||
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
|
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
|
||||||
@@ -1921,7 +1982,7 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [])
|
}, [deleteSelectedSymbol])
|
||||||
|
|
||||||
// Drop zone for symbols — use stable ref connection (no inline ref callback)
|
// Drop zone for symbols — use stable ref connection (no inline ref callback)
|
||||||
const [, drop] = useDrop(() => ({
|
const [, drop] = useDrop(() => ({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
X, ChevronRight, ChevronLeft, SkipForward,
|
X, ChevronRight, ChevronLeft, SkipForward,
|
||||||
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
|
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
|
||||||
|
MousePointer2, CircleDot, Minus, Pentagon, Square, Circle, MoveRight, Type, Eraser,
|
||||||
|
Lock, ClipboardList, Download, AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
|
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
|
||||||
@@ -15,61 +17,96 @@ interface TourStep {
|
|||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
targetSelector?: string
|
targetSelector?: string
|
||||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
tools?: { icon: React.ReactNode; label: string; shortcut?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOUR_STEPS: TourStep[] = [
|
const TOUR_STEPS: TourStep[] = [
|
||||||
{
|
{
|
||||||
title: 'Willkommen bei Lageplan!',
|
title: 'Willkommen bei Lageplan!',
|
||||||
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
||||||
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen. Du kannst sie jederzeit überspringen oder später im Benutzermenü erneut starten.',
|
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Einsatz erstellen',
|
title: 'Einsatz erstellen',
|
||||||
icon: <MapPin className="w-5 h-5 text-red-500" />,
|
icon: <MapPin className="w-5 h-5 text-red-500" />,
|
||||||
description: 'Erstelle über «Neuer Einsatz» ein neues Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin. Jeder Einsatz wird separat gespeichert und kann als PDF oder PNG exportiert werden.',
|
description: 'Erstelle über «Neuer Einsatz» ein Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin.',
|
||||||
targetSelector: '[data-tour="new-project"]',
|
targetSelector: '[data-tour="new-project"]',
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Bearbeitung starten',
|
||||||
|
icon: <Lock className="w-5 h-5 text-green-500" />,
|
||||||
|
description: 'Klicke auf «Bearbeitung starten» um die Karte zu bearbeiten. Nur ein Benutzer gleichzeitig kann bearbeiten — andere sehen deine Änderungen live.',
|
||||||
|
targetSelector: '[data-tour="edit-toggle"]',
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Zeichenwerkzeuge',
|
title: 'Zeichenwerkzeuge',
|
||||||
icon: <Pencil className="w-5 h-5 text-blue-500" />,
|
icon: <Pencil className="w-5 h-5 text-blue-500" />,
|
||||||
description: 'Die Werkzeugleiste links enthält alle Zeichentools: Punkte, Linien, Polygone, Freihand, Pfeile, Text, Radiergummi und mehr. Jedes Tool hat ein Tastenkürzel — drücke «?» für eine Übersicht.',
|
description: 'Links findest du alle Werkzeuge. Jedes hat ein Tastenkürzel:',
|
||||||
targetSelector: '[data-tour="toolbar"]',
|
targetSelector: '[data-tour="toolbar"]',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
|
tools: [
|
||||||
|
{ icon: <MousePointer2 className="w-3.5 h-3.5" />, label: 'Auswählen', shortcut: 'V' },
|
||||||
|
{ icon: <CircleDot className="w-3.5 h-3.5" />, label: 'Punkt', shortcut: 'P' },
|
||||||
|
{ icon: <Minus className="w-3.5 h-3.5" />, label: 'Linie / Schlauch', shortcut: 'L' },
|
||||||
|
{ icon: <Pentagon className="w-3.5 h-3.5" />, label: 'Polygon', shortcut: 'G' },
|
||||||
|
{ icon: <Square className="w-3.5 h-3.5" />, label: 'Rechteck', shortcut: 'R' },
|
||||||
|
{ icon: <Circle className="w-3.5 h-3.5" />, label: 'Kreis', shortcut: 'C' },
|
||||||
|
{ icon: <Pencil className="w-3.5 h-3.5" />, label: 'Freihand', shortcut: 'F' },
|
||||||
|
{ icon: <MoveRight className="w-3.5 h-3.5" />, label: 'Pfeil / Route', shortcut: 'A' },
|
||||||
|
{ icon: <Type className="w-3.5 h-3.5" />, label: 'Text', shortcut: 'T' },
|
||||||
|
{ icon: <Eraser className="w-3.5 h-3.5" />, label: 'Radiergummi', shortcut: 'E' },
|
||||||
|
{ icon: <Ruler className="w-3.5 h-3.5" />, label: 'Messen', shortcut: 'M' },
|
||||||
|
{ icon: <AlertTriangle className="w-3.5 h-3.5" />, label: 'Gefahrenzone', shortcut: 'D' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Symbole & Sidebar',
|
title: 'Symbole (Drag & Drop)',
|
||||||
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
|
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
|
||||||
description: 'Rechts findest du über 100 taktische Feuerwehr-Symbole, sortiert nach Kategorien (Wasser, Feuer, Fahrzeuge usw.). Ziehe sie per Drag & Drop auf die Karte. Wechsle zwischen Symbolen und dem Einsatz-Journal.',
|
description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Ziehe Symbole per Drag & Drop auf die Karte. Klicke auf ein platziertes Symbol um es zu drehen, skalieren oder löschen.',
|
||||||
|
targetSelector: '[data-tour="sidebar"]',
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Journal & Rapport',
|
||||||
|
icon: <ClipboardList className="w-5 h-5 text-indigo-500" />,
|
||||||
|
description: 'Wechsle rechts zum Journal-Tab für das Einsatz-Journal. Erfasse Einträge, SOMA-Checkliste und Pendenzen. Erstelle einen druckfertigen Einsatzrapport als PDF.',
|
||||||
targetSelector: '[data-tour="sidebar"]',
|
targetSelector: '[data-tour="sidebar"]',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Speichern & Export',
|
title: 'Speichern & Export',
|
||||||
icon: <Save className="w-5 h-5 text-green-500" />,
|
icon: <Save className="w-5 h-5 text-green-500" />,
|
||||||
description: 'Speichere deinen Einsatz mit Ctrl+S oder dem Speichern-Button. Exportiere als PNG (Bild) oder als druckfertiges PDF. Die letzte Kartenansicht wird automatisch gespeichert.',
|
description: 'Speichere mit dem Speichern-Button oder Ctrl+S. Exportiere die Karte als PNG oder PDF über das Menü.',
|
||||||
targetSelector: '[data-tour="save"]',
|
targetSelector: '[data-tour="save"]',
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
},
|
tools: [
|
||||||
{
|
{ icon: <Save className="w-3.5 h-3.5" />, label: 'Speichern', shortcut: 'Ctrl+S' },
|
||||||
title: 'Messen & Schlauch-Rechner',
|
{ icon: <Download className="w-3.5 h-3.5" />, label: 'Export als PNG/PDF' },
|
||||||
icon: <Ruler className="w-5 h-5 text-purple-500" />,
|
],
|
||||||
description: 'Mit dem Messwerkzeug (Taste «M») misst du Distanzen direkt auf der Karte. Der Schlauch-Rechner im Admin-Bereich berechnet die benötigten Schlauchlängen und -typen für deinen Einsatz.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Live-Zusammenarbeit',
|
title: 'Live-Zusammenarbeit',
|
||||||
icon: <Users className="w-5 h-5 text-cyan-500" />,
|
icon: <Users className="w-5 h-5 text-cyan-500" />,
|
||||||
description: 'Mehrere Benutzer können gleichzeitig am selben Einsatz arbeiten. Änderungen werden in Echtzeit synchronisiert — ideal für die Einsatzleitung mit mehreren Operateuren.',
|
description: 'Mehrere Benutzer arbeiten gleichzeitig am selben Einsatz. Änderungen werden in Echtzeit synchronisiert.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tastenkürzel (CH)',
|
title: 'Tastenkürzel',
|
||||||
icon: <Keyboard className="w-5 h-5 text-slate-500" />,
|
icon: <Keyboard className="w-5 h-5 text-slate-500" />,
|
||||||
description: 'Optimiert für Schweizer Tastaturen: Ctrl+Y = Rückgängig, Ctrl+Z = Wiederholen, Del = Löschen, Ctrl+S = Speichern. Drücke «?» oder F1 für die komplette Übersicht aller Kürzel.',
|
description: 'Wichtige Kürzel:',
|
||||||
|
tools: [
|
||||||
|
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Z</span>, label: 'Rückgängig' },
|
||||||
|
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Y</span>, label: 'Wiederholen' },
|
||||||
|
{ icon: <span className="text-[10px] font-mono font-bold">Del</span>, label: 'Symbol löschen' },
|
||||||
|
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+S</span>, label: 'Speichern' },
|
||||||
|
{ icon: <span className="text-[10px] font-mono font-bold">?</span>, label: 'Alle Kürzel anzeigen' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bereit für den Einsatz!',
|
title: 'Bereit für den Einsatz!',
|
||||||
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
||||||
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts → «Tour starten») erneut aufrufen. Viel Erfolg im Einsatz — Feuer frei!',
|
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts) erneut starten. Viel Erfolg!',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -224,9 +261,20 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
|
||||||
{step.description}
|
{step.description}
|
||||||
</p>
|
</p>
|
||||||
|
{step.tools && (
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-3 text-xs">
|
||||||
|
{step.tools.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||||
|
<span className="text-muted-foreground shrink-0">{t.icon}</span>
|
||||||
|
<span className="truncate">{t.label}</span>
|
||||||
|
{t.shortcut && <kbd className="ml-auto shrink-0 px-1 py-0.5 bg-muted rounded border border-border font-mono text-[9px]">{t.shortcut}</kbd>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress dots */}
|
{/* Progress dots */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
import type { Project, DrawFeature } from '@/app/app/page'
|
import type { Project, DrawFeature } from '@/types'
|
||||||
import { formatDateTime } from './utils'
|
import { formatDateTime } from './utils'
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user