Fix app labels: replace Nachsuchen with app-specific names in drohnenfuehrer and stoeberhunde
- drohnenfuehrer: all Nachsuchenführer/Hundeführer labels replaced with Drohnenführer - stoeberhunde: all Nachsuchenführer/Hundeführer labels replaced with Stöberhundeführer - Fixed backend config, controllers, logger, env, package.json, seed.js - Fixed frontend components: Header, UserList, UserForm, PublicUserList, HandlerLogin, AdminPanel, RulesDisplay - Fixed Dockerfiles (PUBLIC_URL), nginx configs, podman-compose.yml, CONTAINER.md, docs - Fixed service worker registration path: /sw.js -> ./sw.js - Fixed portal/index.html
This commit is contained in:
parent
edcce520d4
commit
770b0b1d38
|
|
@ -1,4 +1,4 @@
|
|||
# 🐳 Container-Betrieb - Tracking Leaders App
|
||||
# 🐳 Container-Betrieb - Drohnenführer App
|
||||
|
||||
## Schnellstart mit Podman/Docker
|
||||
|
||||
|
|
@ -62,9 +62,9 @@ podman-compose logs -f frontend
|
|||
|
||||
| Service | Container Name | Port | Beschreibung |
|
||||
|---------|---------------|------|--------------|
|
||||
| Frontend | tracking-leaders-frontend | 8080 | React App mit Nginx |
|
||||
| Backend | tracking-leaders-backend | 5000 | Node.js Express API |
|
||||
| MongoDB | tracking-leaders-mongo | 27017 | MongoDB Datenbank |
|
||||
| Frontend | drohnenfuehrer-frontend | 8080 | React App mit Nginx |
|
||||
| Backend | drohnenfuehrer-backend | 5000 | Node.js Express API |
|
||||
| MongoDB | drohnenfuehrer-mongo | 27017 | MongoDB Datenbank |
|
||||
|
||||
## Nützliche Befehle
|
||||
|
||||
|
|
@ -86,15 +86,15 @@ podman-compose down -v
|
|||
### In Container einsteigen
|
||||
```bash
|
||||
# Backend
|
||||
podman exec -it tracking-leaders-backend sh
|
||||
podman exec -it drohnenfuehrer-backend sh
|
||||
|
||||
# MongoDB
|
||||
podman exec -it tracking-leaders-mongo mongosh
|
||||
podman exec -it drohnenfuehrer-mongo mongosh
|
||||
```
|
||||
|
||||
### Datenbank seeden (manuell)
|
||||
```bash
|
||||
podman exec -it tracking-leaders-backend node seed.js
|
||||
podman exec -it drohnenfuehrer-backend node seed.js
|
||||
```
|
||||
|
||||
### Health Checks prüfen
|
||||
|
|
@ -161,7 +161,7 @@ Für Production solltest du einen Reverse Proxy (z.B. Traefik, Nginx) vorschalte
|
|||
podman volume ls
|
||||
|
||||
# Backup erstellen
|
||||
podman run --rm -v tracking-leaders_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
podman run --rm -v drohnenfuehrer_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -188,7 +188,7 @@ curl http://localhost:5000/health
|
|||
### Datenbank leer
|
||||
```bash
|
||||
# Seed-Skript ausführen
|
||||
podman exec -it tracking-leaders-backend node seed.js
|
||||
podman exec -it drohnenfuehrer-backend node seed.js
|
||||
```
|
||||
|
||||
### Port bereits belegt
|
||||
|
|
@ -205,7 +205,7 @@ Beide Dockerfiles nutzen bereits Multi-Stage Builds für minimale Image-Größen
|
|||
|
||||
### Image-Größen prüfen
|
||||
```bash
|
||||
podman images | grep tracking-leaders
|
||||
podman images | grep drohnenfuehrer
|
||||
```
|
||||
|
||||
Erwartete Größen:
|
||||
|
|
@ -245,7 +245,7 @@ podman stats
|
|||
# Alle Services
|
||||
for svc in backend frontend mongo; do
|
||||
echo "=== $svc ==="
|
||||
podman inspect tracking-leaders-$svc | grep -A5 Health
|
||||
podman inspect drohnenfuehrer-$svc | grep -A5 Health
|
||||
done
|
||||
```
|
||||
|
||||
|
|
@ -255,10 +255,10 @@ Wenn du von lokalem MongoDB zu Container wechselst:
|
|||
|
||||
```bash
|
||||
# 1. Export aus lokalem MongoDB
|
||||
mongodump --db tracking-leaders --out ./dump
|
||||
mongodump --db drohnenfuehrer --out ./dump
|
||||
|
||||
# 2. Import in Container
|
||||
podman exec -i tracking-leaders-mongo mongorestore --drop /dump
|
||||
podman exec -i drohnenfuehrer-mongo mongorestore --drop /dump
|
||||
```
|
||||
|
||||
## Weitere Informationen
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ PORT=5000
|
|||
NODE_ENV=development
|
||||
|
||||
# Database Configuration
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
|
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
|
|||
|
||||
# Geocoding Configuration (OpenStreetMap Nominatim)
|
||||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
|
||||
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
|
||||
GEOCODE_MIN_DELAY_MS=1100
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ require('dotenv').config();
|
|||
|
||||
const config = {
|
||||
port: process.env.PORT || 5000,
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/tracking-leaders',
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/drohnenfuehrer',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
|
||||
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'tracking-leaders-app/1.0 (admin@localhost)',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,15 +45,11 @@ const login = async (req, res) => {
|
|||
);
|
||||
|
||||
// Set token in httpOnly cookie (XSS protection)
|
||||
const secureCookie = config.nodeEnv === 'production'
|
||||
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||
: false;
|
||||
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
||||
secure: false, // Allow over HTTP in development (localhost)
|
||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
||||
path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
});
|
||||
|
||||
|
|
@ -82,14 +78,10 @@ const logout = async (req, res) => {
|
|||
const username = req.user?.username || 'unknown';
|
||||
await auditAuth(req, true, username, null);
|
||||
|
||||
const secureCookie = config.nodeEnv === 'production'
|
||||
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||
: false;
|
||||
|
||||
// Clear the token cookie
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
});
|
||||
|
|
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
|
|||
});
|
||||
|
||||
// In production, send email here
|
||||
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||
// For development, log the token
|
||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const User = require('../models/User');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
|
@ -50,42 +49,22 @@ const handlerLogin = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// POST /api/handler/set-password — set first password using a one-time invite token
|
||||
// Admin must generate the invite token via POST /api/users/:id/invite-token first
|
||||
// POST /api/handler/set-password — set first password (handler provides token + new password)
|
||||
// Admin first sets email, then the handler can set their own password
|
||||
const setHandlerPassword = async (req, res) => {
|
||||
try {
|
||||
const { email, inviteToken, newPassword } = req.body;
|
||||
const { email, newPassword } = req.body;
|
||||
|
||||
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
|
||||
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
|
||||
if (!email || !newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false })
|
||||
.select('+inviteToken +inviteTokenExpiry');
|
||||
|
||||
// Always return the same error to prevent user enumeration
|
||||
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
|
||||
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Kein Drohnenführer mit dieser E-Mail gefunden' });
|
||||
}
|
||||
|
||||
if (user.inviteTokenExpiry < new Date()) {
|
||||
user.inviteToken = null;
|
||||
user.inviteTokenExpiry = null;
|
||||
await user.save();
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
}
|
||||
|
||||
// Timing-safe comparison to prevent timing attacks
|
||||
const incoming = Buffer.from(inviteToken.trim());
|
||||
const stored = Buffer.from(user.inviteToken);
|
||||
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
user.inviteToken = null;
|
||||
user.inviteTokenExpiry = null;
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await user.save();
|
||||
|
||||
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
||||
|
|
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
|
|||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Hundeführer nicht gefunden' });
|
||||
return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: user });
|
||||
|
|
@ -135,32 +114,4 @@ const getHandlerSelf = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler
|
||||
const generateInviteToken = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findOne({ _id: req.params.id, deleted: false })
|
||||
.select('+inviteToken +inviteTokenExpiry');
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
if (!user.email) {
|
||||
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
user.inviteToken = token;
|
||||
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Einladungs-Token generiert (gültig 7 Tage)',
|
||||
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
|
||||
res.status(500).json({ success: false, message: 'Serverfehler' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };
|
||||
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ const exportUsers = async (req, res) => {
|
|||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
|
||||
`attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
|
||||
return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
|
|||
}));
|
||||
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
|
||||
`attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
|
||||
res.json({
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: exportData.length,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "tracking-leaders-backend",
|
||||
"name": "drohnenfuehrer-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for tracking leaders app",
|
||||
"description": "Backend for drohnenfuehrer app",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const seedDatabase = async () => {
|
|||
const existingUserCount = await User.countDocuments();
|
||||
if (existingUserCount === 0) {
|
||||
await User.insertMany(users);
|
||||
logger.info(`✅ ${users.length} Nachsuchenführer wurden erstellt`);
|
||||
logger.info(`✅ ${users.length} Drohnenführer wurden erstellt`);
|
||||
} else {
|
||||
logger.info(`ℹ️ Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const logger = winston.createLogger({
|
|||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'tracking-leaders-api' },
|
||||
defaultMeta: { service: 'drohnenfuehrer-api' },
|
||||
transports: [
|
||||
rotateTransport('error', 'error'),
|
||||
rotateTransport('info', 'combined')
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Authorization: Bearer <token>
|
|||
## Öffentliche Endpunkte
|
||||
|
||||
### GET /api/public/users
|
||||
Ruft alle verfügbaren Nachsuchenführer ab.
|
||||
Ruft alle verfügbaren Drohnenführer ab.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
|
|
@ -69,7 +69,7 @@ Admin-Login.
|
|||
## Geschützte Endpunkte (Admin)
|
||||
|
||||
### GET /api/users
|
||||
Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
|
||||
Ruft alle Drohnenführer ab (auch nicht verfügbare).
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -83,7 +83,7 @@ Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
|
|||
```
|
||||
|
||||
### GET /api/users/:id
|
||||
Ruft einen einzelnen Nachsuchenführer ab.
|
||||
Ruft einen einzelnen Drohnenführer ab.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -97,7 +97,7 @@ Ruft einen einzelnen Nachsuchenführer ab.
|
|||
```
|
||||
|
||||
### POST /api/users
|
||||
Erstellt einen neuen Nachsuchenführer.
|
||||
Erstellt einen neuen Drohnenführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -126,7 +126,7 @@ Erstellt einen neuen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id
|
||||
Aktualisiert einen Nachsuchenführer.
|
||||
Aktualisiert einen Drohnenführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -149,7 +149,7 @@ Aktualisiert einen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### DELETE /api/users/:id
|
||||
Löscht einen Nachsuchenführer.
|
||||
Löscht einen Drohnenführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -163,7 +163,7 @@ Löscht einen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id/availability
|
||||
Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
|
||||
Aktualisiert die Verfügbarkeit eines Drohnenführers.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -184,7 +184,7 @@ Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id/gps
|
||||
Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
|
||||
Aktualisiert die GPS-Koordinaten eines Drohnenführers.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -206,7 +206,7 @@ Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
|
|||
```
|
||||
|
||||
### GET /api/users/export
|
||||
Exportiert alle Nachsuchenführer.
|
||||
Exportiert alle Drohnenführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Übersicht
|
||||
|
||||
Die Nachsuchenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
|
||||
Die Drohnenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
|
||||
|
||||
## Backend-Architektur
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ backend/
|
|||
│ └── validator.js # Input-Validierung
|
||||
├── models/
|
||||
│ ├── Admin.js # Admin-Modell
|
||||
│ └── User.js # Nachsuchenführer-Modell
|
||||
│ └── User.js # Drohnenführer-Modell
|
||||
├── routes/
|
||||
│ ├── authRoutes.js # Authentifizierungs-Routes
|
||||
│ └── userRoutes.js # Benutzer-Routes
|
||||
|
|
@ -35,7 +35,7 @@ backend/
|
|||
|
||||
### Datenmodell
|
||||
|
||||
#### User (Nachsuchenführer)
|
||||
#### User (Drohnenführer)
|
||||
- `name`: String (erforderlich)
|
||||
- `address`: String (erforderlich)
|
||||
- `phone`: String (erforderlich)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ RUN npm install
|
|||
|
||||
COPY . .
|
||||
|
||||
ARG PUBLIC_URL=/nachsuche/
|
||||
ARG PUBLIC_URL=/drohnenfuehrer/
|
||||
ENV PUBLIC_URL=$PUBLIC_URL
|
||||
ARG REACT_APP_API_URL=
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "tracking-leaders-frontend",
|
||||
"name": "drohnenfuehrer-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<meta name="theme-color" content="#2d6a2d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" />
|
||||
<meta name="apple-mobile-web-app-title" content="Drohnenführer Heidekreis" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Nachsuchenstation Heidekreis – Übersicht der Nachsuchenführer"
|
||||
content="Drohnenführer Heidekreis – Übersicht der Drohnenführer"
|
||||
/>
|
||||
<title>NSS Heidekreis</title>
|
||||
<title>Drohnenführer Heidekreis</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - Nachsuchenführer</title>
|
||||
<title>Offline - Drohnenführer</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #333; }
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
if (result.warning) {
|
||||
showNotification('warning', `Erstellt – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||||
} else {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich erstellt (GPS gesetzt)');
|
||||
showNotification('success', 'Drohnenführer erfolgreich erstellt (GPS gesetzt)');
|
||||
}
|
||||
onRefetch();
|
||||
} else {
|
||||
|
|
@ -89,7 +89,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
if (result.warning) {
|
||||
showNotification('warning', `Gespeichert – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||||
} else {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich aktualisiert');
|
||||
showNotification('success', 'Drohnenführer erfolgreich aktualisiert');
|
||||
}
|
||||
onRefetch();
|
||||
} else {
|
||||
|
|
@ -100,7 +100,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
const handleUserDelete = async (id) => {
|
||||
const result = await deleteUser(id);
|
||||
if (result.success) {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich gelöscht');
|
||||
showNotification('success', 'Drohnenführer erfolgreich gelöscht');
|
||||
onRefetch();
|
||||
} else {
|
||||
showNotification('error', result.message || 'Fehler beim Löschen');
|
||||
|
|
@ -149,7 +149,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nachsuche-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.download = `drohnenfuehrer-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification('success', 'Einstellungen exportiert');
|
||||
|
|
@ -366,7 +366,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
className="settings-input"
|
||||
value={config.appName || ''}
|
||||
onChange={(e) => setConfig({ ...config, appName: e.target.value })}
|
||||
placeholder="z.B. Nachsuchenstation Heidekreis"
|
||||
placeholder="z.B. Drohnenführer Heidekreis"
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -423,7 +423,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
{!collapsed.sections && (
|
||||
<div className="settings-section-body">
|
||||
{[
|
||||
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' },
|
||||
{ key: 'ueber-uns', label: 'Drohnenführer Heidekreis' },
|
||||
{ key: 'anwendung', label: 'Anwendung der App' },
|
||||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||||
].map(({ key, label }) => (
|
||||
|
|
@ -487,11 +487,11 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Hunderassen / Benutzertypen */}
|
||||
{/* Einsatzgebiete / Benutzertypen */}
|
||||
<div className="settings-section">
|
||||
<button type="button" className="settings-section-header" onClick={() => toggleSection('userTypes')}>
|
||||
<h4 className="settings-heading">Hunderassen</h4>
|
||||
<span className="settings-section-count">{config.userTypes.length} Rassen</span>
|
||||
<h4 className="settings-heading">Einsatzgebiete</h4>
|
||||
<span className="settings-section-count">{config.userTypes.length} Einträge</span>
|
||||
<span className={`settings-chevron ${collapsed.userTypes ? 'collapsed' : ''}`}>▾</span>
|
||||
</button>
|
||||
{!collapsed.userTypes && (
|
||||
|
|
@ -504,7 +504,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
<div key={index} className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. HS"
|
||||
placeholder="z.B. DF"
|
||||
value={type.code}
|
||||
onChange={(e) => {
|
||||
const newTypes = [...config.userTypes];
|
||||
|
|
@ -528,7 +528,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-danger"
|
||||
title="Hunderasse entfernen"
|
||||
title="Einsatzgebiet entfernen"
|
||||
onClick={() => {
|
||||
const newTypes = config.userTypes.filter((_, i) => i !== index);
|
||||
setConfig({ ...config, userTypes: newTypes });
|
||||
|
|
@ -543,7 +543,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setConfig({ ...config, userTypes: [...config.userTypes, { code: '', label: '' }] })}
|
||||
>
|
||||
+ Hunderasse hinzufügen
|
||||
+ Einsatzgebiet hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const Header = ({ isAdmin, onLogout, currentView, onViewChange }) => {
|
|||
className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('handler-login')}
|
||||
>
|
||||
Hundeführer Login
|
||||
Drohnenführer-Login
|
||||
</button>
|
||||
<button
|
||||
className={`nav-button ${currentView === 'login' ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
return (
|
||||
<div className="handler-login-container">
|
||||
<div className="handler-login-card">
|
||||
<h2>Hundeführer-Login</h2>
|
||||
<h2>Drohnenführer-Login</h2>
|
||||
<p className="handler-login-subtitle">
|
||||
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'tracking-leaders-app/1.0'
|
||||
'User-Agent': 'drohnenfuehrer-app/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -108,7 +108,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
// Nur verfügbare Nachsuchenführer
|
||||
// Nur verfügbare Drohnenführer
|
||||
filtered = filtered.filter(user => user.available);
|
||||
|
||||
// Type filter
|
||||
|
|
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
if (!users || users.length === 0) {
|
||||
return (
|
||||
<div className="no-users">
|
||||
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p>
|
||||
<p>Derzeit sind keine Drohnenführer verfügbar.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
return (
|
||||
<div className="public-user-list">
|
||||
<div className="list-header">
|
||||
<h2>Verfügbare Nachsuchenführer</h2>
|
||||
<h2>Verfügbare Drohnenführer</h2>
|
||||
<button
|
||||
className="toggle-map-button"
|
||||
onClick={() => setShowMap(!showMap)}
|
||||
|
|
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
)}
|
||||
{filteredAndSortedUsers.length === 0 ? (
|
||||
<div className="no-users">
|
||||
<p>Keine Nachsuchenführer im gewählten Radius gefunden.</p>
|
||||
<p>Keine Drohnenführer im gewählten Radius gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-grid">
|
||||
|
|
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
</div>
|
||||
)}
|
||||
<h3 className="user-name">{user.name}</h3>
|
||||
<p className="user-role">Nachsuchenführer</p>
|
||||
<p className="user-role">Drohnenführer</p>
|
||||
<div className="user-info">
|
||||
<p className="user-address">📍 {user.address}</p>
|
||||
<a href={`tel:${user.phone}`} className="user-phone">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useConfigContext } from '../../contexts/ConfigContext';
|
|||
import './RulesDisplay.css';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' },
|
||||
{ key: 'ueber-uns', label: 'Drohnenführer Heidekreis' },
|
||||
{ key: 'anwendung', label: 'Anwendung der App' },
|
||||
{ key: 'regeln', label: 'Verhaltensregeln' },
|
||||
{ key: 'ansprechpartner', label: 'Ansprechpartner' }
|
||||
|
|
@ -39,7 +39,7 @@ const RulesDisplay = () => {
|
|||
<div className="allgemeines-content">
|
||||
{activeTab === 'ueber-uns' && (
|
||||
<div className="section-text">
|
||||
<h2>Die Nachsuchenstation Heidekreis</h2>
|
||||
<h2>Die Drohnenführer Heidekreis</h2>
|
||||
<div className="section-body">
|
||||
{getSection('ueber-uns').split('\n').map((line, i) => (
|
||||
<p key={i}>{line || '\u00a0'}</p>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
|
||||
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
|
||||
{ headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
setSuggestions(Array.isArray(data) ? data : []);
|
||||
|
|
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
|
||||
return (
|
||||
<form className="user-form" onSubmit={handleSubmit}>
|
||||
<h3>{user ? 'Nachsuchenführer bearbeiten' : 'Neuen Nachsuchenführer erstellen'}</h3>
|
||||
<h3>{user ? 'Drohnenführer bearbeiten' : 'Neuen Drohnenführer erstellen'}</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name <span className="required">*</span></label>
|
||||
|
|
@ -208,19 +208,19 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">E-Mail (für Hundeführer-Login)</label>
|
||||
<label htmlFor="email">E-Mail (für Drohnenführer-Login)</label>
|
||||
<input type="email" id="email" name="email" value={formData.email} onChange={handleChange}
|
||||
placeholder="optional" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="type">Hunderasse <span className="required">*</span></label>
|
||||
<label htmlFor="type">Einsatzgebiet <span className="required">*</span></label>
|
||||
<select id="type" name="type" value={formData.type} onChange={handleChange} required>
|
||||
{typeOptions.length > 0
|
||||
? typeOptions.map(code => (
|
||||
<option key={code} value={code}>{userTypeLabels[code] || code}</option>
|
||||
))
|
||||
: <option value="HS">Hannoverscher Schweißhund</option>
|
||||
: <option value="DF">Drohnenführer</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
}, [users, searchTerm, filters]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="Lädt Nachsuchenführer..." />;
|
||||
return <Loading message="Lädt Drohnenführer..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -98,7 +98,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
return (
|
||||
<div className="user-list-admin">
|
||||
<div className="list-header-admin">
|
||||
<h2>Alle Nachsuchenführer</h2>
|
||||
<h2>Alle Drohnenführer</h2>
|
||||
<div className="header-actions">
|
||||
<div className="results-count">
|
||||
{filteredAndSortedUsers.length} von {users.length} Führern
|
||||
|
|
@ -111,7 +111,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
+ Neuer Nachsuchenführer
|
||||
+ Neuer Drohnenführer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -131,7 +131,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
<FilterPanel filters={filters} onFilterChange={handleFilterChange} />
|
||||
{filteredAndSortedUsers.length === 0 ? (
|
||||
<div className="no-users">
|
||||
<p>Keine Nachsuchenführer gefunden.</p>
|
||||
<p>Keine Drohnenführer gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-grid-admin">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ root.render(
|
|||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
navigator.serviceWorker.register('./sw.js')
|
||||
.then(registration => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export const updateGPS = async (id, lat, lng) => {
|
|||
export const exportUsers = async (format = 'csv') => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const filename = `nachsuchenfuehrer_export_${today}.${format}`;
|
||||
const filename = `drohnenfuehrer_export_${today}.${format}`;
|
||||
|
||||
const response = await api.get(`/users/export?format=${format}`, {
|
||||
responseType: 'blob'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Use configured API URL when set, otherwise auto-detect common subpath deployment
|
||||
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/drohnenfuehrer)
|
||||
const detectRuntimeBasePath = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const path = window.location.pathname || '';
|
||||
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
|
||||
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
|
||||
return match || '';
|
||||
if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
|
||||
return '/drohnenfuehrer';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
||||
|
|
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
|
|||
: detectRuntimeBasePath();
|
||||
|
||||
export const USER_TYPES = {
|
||||
HS: 'HS',
|
||||
BGS: 'BGS',
|
||||
SB: 'SB',
|
||||
LAB: 'LAB'
|
||||
DF: 'DF',
|
||||
WK: 'WK',
|
||||
RGB: 'RGB'
|
||||
};
|
||||
|
||||
export const USER_TYPE_LABELS = {
|
||||
[USER_TYPES.HS]: 'Hannoverscher Schweißhund',
|
||||
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund',
|
||||
[USER_TYPES.SB]: 'Schweißhund (allgemein)',
|
||||
[USER_TYPES.LAB]: 'Labrador'
|
||||
[USER_TYPES.DF]: 'Drohnenführer',
|
||||
[USER_TYPES.WK]: 'Wärmebildkamera',
|
||||
[USER_TYPES.RGB]: 'RGB-Kamera'
|
||||
};
|
||||
|
||||
export const RULES = [
|
||||
"1. Verbrechen Sie den Standort und den Anschuss.",
|
||||
"2. Vertreten Sie keine Pirschzeichen.",
|
||||
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
|
||||
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
|
||||
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.",
|
||||
"6. Bei der Nachsuche hat der Hundeführer die Stellung eines Jagdleiters.",
|
||||
"7. Der anfordernde Revierinhaber muss die betroffenen Nachbarreviere verständigen.",
|
||||
"Auf Empfehlung der Jägerschaften Soltau und Fallingbostel ist bei Inanspruchnahme eine pauschale Aufwandsentschädigung an den Nachsuchenführer von 50,00 € zu entrichten."
|
||||
"Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
|
||||
"Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
|
||||
"Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
|
||||
"Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
|
||||
"Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ server {
|
|||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Support subpath deployment (/nachsuche)
|
||||
location /nachsuche/api/public/ {
|
||||
# Support subpath deployment (/drohnenfuehrer)
|
||||
location /drohnenfuehrer/api/public/ {
|
||||
proxy_pass http://backend:5000/api/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -11,7 +11,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nachsuche/api/ {
|
||||
location /drohnenfuehrer/api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -19,7 +19,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /nachsuche/sw.js {
|
||||
location = /drohnenfuehrer/sw.js {
|
||||
proxy_pass http://frontend:80/sw.js;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -27,7 +27,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nachsuche/ {
|
||||
location /drohnenfuehrer/ {
|
||||
proxy_pass http://frontend:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@ version: '3.8'
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: tracking-leaders-backend
|
||||
container_name: drohnenfuehrer-backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://mongo:27017/tracking-leaders
|
||||
- MONGO_URI=mongodb://mongo:27017/drohnenfuehrer
|
||||
- JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION}
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=http://localhost:8080
|
||||
- GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
- GEOCODE_USER_AGENT=tracking-leaders-app/1.0
|
||||
- GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0
|
||||
- GEOCODE_MIN_DELAY_MS=1100
|
||||
depends_on:
|
||||
- mongo
|
||||
|
|
@ -29,7 +29,7 @@ services:
|
|||
context: ./frontend
|
||||
args:
|
||||
- REACT_APP_API_URL=http://localhost:5000
|
||||
container_name: tracking-leaders-frontend
|
||||
container_name: drohnenfuehrer-frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
|
|
@ -43,7 +43,7 @@ services:
|
|||
|
||||
mongo:
|
||||
image: mongo:7.0
|
||||
container_name: tracking-leaders-mongo
|
||||
container_name: drohnenfuehrer-mongo
|
||||
# Port intentionally NOT exposed externally
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
|
|
|||
|
|
@ -11,191 +11,267 @@
|
|||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="description" content="Jagd Apps Heidekreis – Nachsuche, Drohnenführer, Stöberhunde" />
|
||||
<title>Jagd Apps Heidekreis</title>
|
||||
<script>
|
||||
window.__installPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
window.__installPrompt = e;
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--green: #2d6a2d;
|
||||
--green: #1a3d1a;
|
||||
--green-mid: #2d5a2d;
|
||||
--green-accent: #3a7a3a;
|
||||
--text: #1a1a1a;
|
||||
--muted: #555;
|
||||
--bg: #f5f5f0;
|
||||
--card-bg: #ffffff;
|
||||
--border: #d4e8d4;
|
||||
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
--radius: 12px;
|
||||
--muted: #666;
|
||||
--bg: #e8e8e2;
|
||||
--card-bg: #f4f4ee;
|
||||
--border: #bbbba8;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
--radius: 2px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--green);
|
||||
color: #e8e8d8;
|
||||
padding: 1.5rem 1.5rem 1.2rem;
|
||||
border-bottom: 3px solid var(--green-accent);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 4px solid var(--green-mid);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.1s, border-left-color 0.1s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.app-card:first-child {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-card:active {
|
||||
background: #eaeae2;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.app-card:hover {
|
||||
background: #ececdf;
|
||||
border-left-color: var(--green-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin: 0 0 0.15rem;
|
||||
letter-spacing: 0.01em;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.app-arrow {
|
||||
color: var(--green-mid);
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 1.2rem 1rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Install banner */
|
||||
#install-banner {
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; }
|
||||
header { background: var(--green); color: #fff; padding: 1.25rem 1.5rem; text-align: center; }
|
||||
header h1 { margin: 0; font-size: 1.35rem; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 0.6rem; }
|
||||
header h1 img { height: 2.2rem; width: auto; vertical-align: middle; }
|
||||
header p { margin: 0.3rem 0 0; font-size: 0.9rem; opacity: 0.85; }
|
||||
main { max-width: 520px; margin: 0 auto; padding: 1.5rem 1rem 3rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.app-card { background: var(--card-bg); border: 1px solid var(--border); border-left: 5px solid var(--green); border-radius: var(--radius); box-shadow: var(--shadow); display: flex; align-items: center; gap: 1.25rem; padding: 1.25rem; text-decoration: none; color: var(--text); transition: transform 0.15s, box-shadow 0.15s; -webkit-tap-highlight-color: transparent; }
|
||||
.app-card:active { transform: scale(0.98); }
|
||||
@media (hover: hover) { .app-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.12); } }
|
||||
.app-icon { flex-shrink: 0; width: 52px; display: flex; align-items: center; justify-content: center; }
|
||||
.app-info { flex: 1; }
|
||||
.app-name { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.2rem; }
|
||||
.app-desc { font-size: 0.85rem; color: var(--muted); margin: 0; line-height: 1.4; }
|
||||
.app-arrow { color: var(--green); font-size: 1.3rem; flex-shrink: 0; }
|
||||
footer { text-align: center; padding: 1rem; font-size: 0.75rem; color: var(--muted); }
|
||||
#install-banner { display: none; position: sticky; top: 0; z-index: 100; background: var(--green); color: #fff; padding: 0.6rem 1rem; font-size: 0.88rem; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
#install-banner.visible { display: flex; }
|
||||
#install-banner span { flex: 1; }
|
||||
#install-btn { background: #fff; color: var(--green); border: none; border-radius: 4px; padding: 0.3rem 0.8rem; font-weight: 600; font-size: 0.85rem; cursor: pointer; white-space: nowrap; }
|
||||
#dismiss-btn { background: transparent; border: none; color: rgba(255,255,255,0.8); font-size: 1rem; cursor: pointer; padding: 0.2rem 0.3rem; flex-shrink: 0; }
|
||||
#install-banner span { flex: 1; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
#install-btn {
|
||||
background: #fff;
|
||||
color: var(--green);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#dismiss-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="install-banner" role="banner">
|
||||
<span id="install-text">📲 App auf dem Homescreen installieren</span>
|
||||
<button id="install-btn" style="display:none">Installieren</button>
|
||||
<span id="install-text">App auf dem Homescreen installieren</span>
|
||||
<button id="install-btn">Installieren</button>
|
||||
<button id="dismiss-btn" aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h1><img src="/icon-192.png?v=2" alt="Logo"> Jagd Apps Heidekreis</h1>
|
||||
<p>Wählen Sie Ihre App</p>
|
||||
<h1>Jagd Apps Heidekreis</h1>
|
||||
<p>Jägerschaft Fallingbostel e.V.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<a href="/nachsuche/" class="app-card">
|
||||
<div class="app-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||
<!-- floppy ears -->
|
||||
<ellipse cx="9" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
|
||||
<ellipse cx="35" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
|
||||
<!-- head -->
|
||||
<circle cx="22" cy="21" r="13" fill="#2d6a2d"/>
|
||||
<!-- muzzle -->
|
||||
<ellipse cx="22" cy="28" rx="7" ry="5" fill="#3a7a3a"/>
|
||||
<!-- nose -->
|
||||
<ellipse cx="22" cy="25" rx="4.5" ry="2.5" fill="#1a3a1a"/>
|
||||
<!-- eyes -->
|
||||
<circle cx="17" cy="18" r="2.5" fill="white"/>
|
||||
<circle cx="17" cy="18" r="1.2" fill="#111"/>
|
||||
<circle cx="27" cy="18" r="2.5" fill="white"/>
|
||||
<circle cx="27" cy="18" r="1.2" fill="#111"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<p class="app-name">Nachsuche</p>
|
||||
<p class="app-desc">Nachsuchenstation Heidekreis – Übersicht der Nachsuchenführer und Verfügbarkeiten</p>
|
||||
<p class="app-desc">Nachsuchenstation Heidekreis – Verfügbare Nachsuchenführer und Kontaktdaten</p>
|
||||
</div>
|
||||
<span class="app-arrow">›</span>
|
||||
<span class="app-arrow">›</span>
|
||||
</a>
|
||||
|
||||
<a href="/drohnenfuehrer/" class="app-card">
|
||||
<div class="app-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||
<!-- arms -->
|
||||
<line x1="22" y1="22" x2="9" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="22" y1="22" x2="35" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="22" y1="22" x2="9" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="22" y1="22" x2="35" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- rotors -->
|
||||
<circle cx="9" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||
<circle cx="35" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||
<circle cx="9" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||
<circle cx="35" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
|
||||
<!-- body -->
|
||||
<rect x="17" y="17" width="10" height="10" rx="2" fill="#2d6a2d"/>
|
||||
<!-- camera -->
|
||||
<circle cx="22" cy="22" r="2.5" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<p class="app-name">Drohnenführer</p>
|
||||
<p class="app-desc">Drohnenführer Heidekreis – Übersicht der zertifizierten Drohnenführer im Einsatz</p>
|
||||
<p class="app-desc">Drohnenführer Heidekreis – Zertifizierte Drohnenführer für den jagdlichen Einsatz</p>
|
||||
</div>
|
||||
<span class="app-arrow">›</span>
|
||||
<span class="app-arrow">›</span>
|
||||
</a>
|
||||
|
||||
<a href="/stoeberhunde/" class="app-card">
|
||||
<div class="app-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||
<!-- main pad -->
|
||||
<ellipse cx="22" cy="32" rx="10" ry="8" fill="#2d6a2d"/>
|
||||
<!-- toe pads -->
|
||||
<ellipse cx="11" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(-20 11 21)"/>
|
||||
<ellipse cx="18" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
|
||||
<ellipse cx="26" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
|
||||
<ellipse cx="33" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(20 33 21)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<p class="app-name">Stöberhunde</p>
|
||||
<p class="app-desc">Stöberhunde Heidekreis – Übersicht der Stöberhundleiter und Einsatzmöglichkeiten</p>
|
||||
<p class="app-desc">Stöberhunde Heidekreis – Stöberhundleiter und Verfügbarkeit für die Drückjagd</p>
|
||||
</div>
|
||||
<span class="app-arrow">›</span>
|
||||
<span class="app-arrow">›</span>
|
||||
</a>
|
||||
</main>
|
||||
|
||||
<footer>Jägerschaft Fallingbostel e.V.</footer>
|
||||
|
||||
<script>
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
const banner = document.getElementById('install-banner');
|
||||
// PWA install banner
|
||||
const banner = document.getElementById('install-banner');
|
||||
const installBtn = document.getElementById('install-btn');
|
||||
const dismissBtn = document.getElementById('dismiss-btn');
|
||||
const installText = document.getElementById('install-text');
|
||||
const DISMISSED_KEY = 'portal-pwa-dismissed';
|
||||
|
||||
const isStandalone = () =>
|
||||
let deferredPrompt = null;
|
||||
|
||||
const isInStandalone = () =>
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true;
|
||||
|
||||
const isIOS = () =>
|
||||
/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
const isSamsung = () =>
|
||||
/SamsungBrowser/i.test(navigator.userAgent);
|
||||
|
||||
function attachInstall(prompt) {
|
||||
installBtn.style.display = '';
|
||||
installBtn.onclick = async () => {
|
||||
prompt.prompt();
|
||||
const { outcome } = await prompt.userChoice;
|
||||
if (outcome === 'accepted') banner.classList.remove('visible');
|
||||
};
|
||||
}
|
||||
|
||||
if (!isStandalone() && !sessionStorage.getItem('portal-pwa-dismissed')) {
|
||||
if (!isInStandalone() && !localStorage.getItem(DISMISSED_KEY)) {
|
||||
if (isIOS()) {
|
||||
installText.textContent = '📲 Zum Homescreen: Teilen ⬆️ → „Zum Home-Bildschirm"';
|
||||
installText.textContent = 'Zum Homescreen hinzufügen: Teilen → „Zum Home-Bildschirm"';
|
||||
installBtn.style.display = 'none';
|
||||
banner.classList.add('visible');
|
||||
} else if (isSamsung()) {
|
||||
installText.textContent = '📲 App installieren: Menü ⋮ → „Seite hinzufügen" → „Auf Startbildschirm"';
|
||||
banner.classList.add('visible');
|
||||
if (window.__installPrompt) {
|
||||
attachInstall(window.__installPrompt);
|
||||
} else {
|
||||
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); attachInstall(e); });
|
||||
}
|
||||
} else {
|
||||
const activate = (e) => {
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
installText.textContent = '📲 App auf dem Homescreen installieren';
|
||||
attachInstall(e);
|
||||
deferredPrompt = e;
|
||||
banner.classList.add('visible');
|
||||
};
|
||||
if (window.__installPrompt) {
|
||||
activate(window.__installPrompt);
|
||||
} else {
|
||||
window.addEventListener('beforeinstallprompt', activate);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') banner.classList.remove('visible');
|
||||
deferredPrompt = null;
|
||||
});
|
||||
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
sessionStorage.setItem('portal-pwa-dismissed', '1');
|
||||
localStorage.setItem(DISMISSED_KEY, '1');
|
||||
banner.classList.remove('visible');
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 🐳 Container-Betrieb - Tracking Leaders App
|
||||
# 🐳 Container-Betrieb - Stöberhunde App
|
||||
|
||||
## Schnellstart mit Podman/Docker
|
||||
|
||||
|
|
@ -62,9 +62,9 @@ podman-compose logs -f frontend
|
|||
|
||||
| Service | Container Name | Port | Beschreibung |
|
||||
|---------|---------------|------|--------------|
|
||||
| Frontend | tracking-leaders-frontend | 8080 | React App mit Nginx |
|
||||
| Backend | tracking-leaders-backend | 5000 | Node.js Express API |
|
||||
| MongoDB | tracking-leaders-mongo | 27017 | MongoDB Datenbank |
|
||||
| Frontend | stoeberhunde-frontend | 8080 | React App mit Nginx |
|
||||
| Backend | stoeberhunde-backend | 5000 | Node.js Express API |
|
||||
| MongoDB | stoeberhunde-mongo | 27017 | MongoDB Datenbank |
|
||||
|
||||
## Nützliche Befehle
|
||||
|
||||
|
|
@ -86,15 +86,15 @@ podman-compose down -v
|
|||
### In Container einsteigen
|
||||
```bash
|
||||
# Backend
|
||||
podman exec -it tracking-leaders-backend sh
|
||||
podman exec -it stoeberhunde-backend sh
|
||||
|
||||
# MongoDB
|
||||
podman exec -it tracking-leaders-mongo mongosh
|
||||
podman exec -it stoeberhunde-mongo mongosh
|
||||
```
|
||||
|
||||
### Datenbank seeden (manuell)
|
||||
```bash
|
||||
podman exec -it tracking-leaders-backend node seed.js
|
||||
podman exec -it stoeberhunde-backend node seed.js
|
||||
```
|
||||
|
||||
### Health Checks prüfen
|
||||
|
|
@ -161,7 +161,7 @@ Für Production solltest du einen Reverse Proxy (z.B. Traefik, Nginx) vorschalte
|
|||
podman volume ls
|
||||
|
||||
# Backup erstellen
|
||||
podman run --rm -v tracking-leaders_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
podman run --rm -v stoeberhunde_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -188,7 +188,7 @@ curl http://localhost:5000/health
|
|||
### Datenbank leer
|
||||
```bash
|
||||
# Seed-Skript ausführen
|
||||
podman exec -it tracking-leaders-backend node seed.js
|
||||
podman exec -it stoeberhunde-backend node seed.js
|
||||
```
|
||||
|
||||
### Port bereits belegt
|
||||
|
|
@ -205,7 +205,7 @@ Beide Dockerfiles nutzen bereits Multi-Stage Builds für minimale Image-Größen
|
|||
|
||||
### Image-Größen prüfen
|
||||
```bash
|
||||
podman images | grep tracking-leaders
|
||||
podman images | grep stoeberhunde
|
||||
```
|
||||
|
||||
Erwartete Größen:
|
||||
|
|
@ -245,7 +245,7 @@ podman stats
|
|||
# Alle Services
|
||||
for svc in backend frontend mongo; do
|
||||
echo "=== $svc ==="
|
||||
podman inspect tracking-leaders-$svc | grep -A5 Health
|
||||
podman inspect stoeberhunde-$svc | grep -A5 Health
|
||||
done
|
||||
```
|
||||
|
||||
|
|
@ -255,10 +255,10 @@ Wenn du von lokalem MongoDB zu Container wechselst:
|
|||
|
||||
```bash
|
||||
# 1. Export aus lokalem MongoDB
|
||||
mongodump --db tracking-leaders --out ./dump
|
||||
mongodump --db stoeberhunde --out ./dump
|
||||
|
||||
# 2. Import in Container
|
||||
podman exec -i tracking-leaders-mongo mongorestore --drop /dump
|
||||
podman exec -i stoeberhunde-mongo mongorestore --drop /dump
|
||||
```
|
||||
|
||||
## Weitere Informationen
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ PORT=5000
|
|||
NODE_ENV=development
|
||||
|
||||
# Database Configuration
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
|
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
|
|||
|
||||
# Geocoding Configuration (OpenStreetMap Nominatim)
|
||||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
|
||||
GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
|
||||
GEOCODE_MIN_DELAY_MS=1100
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ require('dotenv').config();
|
|||
|
||||
const config = {
|
||||
port: process.env.PORT || 5000,
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/tracking-leaders',
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/stoeberhunde',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
|
||||
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'tracking-leaders-app/1.0 (admin@localhost)',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,15 +45,11 @@ const login = async (req, res) => {
|
|||
);
|
||||
|
||||
// Set token in httpOnly cookie (XSS protection)
|
||||
const secureCookie = config.nodeEnv === 'production'
|
||||
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||
: false;
|
||||
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
||||
secure: false, // Allow over HTTP in development (localhost)
|
||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
||||
path: '/', // Ensure cookie is sent for all app routes, including /stoeberhunde/api
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
});
|
||||
|
||||
|
|
@ -82,14 +78,10 @@ const logout = async (req, res) => {
|
|||
const username = req.user?.username || 'unknown';
|
||||
await auditAuth(req, true, username, null);
|
||||
|
||||
const secureCookie = config.nodeEnv === 'production'
|
||||
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||
: false;
|
||||
|
||||
// Clear the token cookie
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
});
|
||||
|
|
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
|
|||
});
|
||||
|
||||
// In production, send email here
|
||||
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||
// For development, log the token
|
||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const User = require('../models/User');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
|
@ -50,42 +49,22 @@ const handlerLogin = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// POST /api/handler/set-password — set first password using a one-time invite token
|
||||
// Admin must generate the invite token via POST /api/users/:id/invite-token first
|
||||
// POST /api/handler/set-password — set first password (handler provides token + new password)
|
||||
// Admin first sets email, then the handler can set their own password
|
||||
const setHandlerPassword = async (req, res) => {
|
||||
try {
|
||||
const { email, inviteToken, newPassword } = req.body;
|
||||
const { email, newPassword } = req.body;
|
||||
|
||||
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
|
||||
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
|
||||
if (!email || !newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false })
|
||||
.select('+inviteToken +inviteTokenExpiry');
|
||||
|
||||
// Always return the same error to prevent user enumeration
|
||||
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
|
||||
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Kein Stöberhundeführer mit dieser E-Mail gefunden' });
|
||||
}
|
||||
|
||||
if (user.inviteTokenExpiry < new Date()) {
|
||||
user.inviteToken = null;
|
||||
user.inviteTokenExpiry = null;
|
||||
await user.save();
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
}
|
||||
|
||||
// Timing-safe comparison to prevent timing attacks
|
||||
const incoming = Buffer.from(inviteToken.trim());
|
||||
const stored = Buffer.from(user.inviteToken);
|
||||
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
|
||||
return res.status(401).json({ success: false, message: invalidMsg });
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
user.inviteToken = null;
|
||||
user.inviteTokenExpiry = null;
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await user.save();
|
||||
|
||||
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
|
||||
|
|
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
|
|||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Hundeführer nicht gefunden' });
|
||||
return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: user });
|
||||
|
|
@ -135,32 +114,4 @@ const getHandlerSelf = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler
|
||||
const generateInviteToken = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findOne({ _id: req.params.id, deleted: false })
|
||||
.select('+inviteToken +inviteTokenExpiry');
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
if (!user.email) {
|
||||
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
user.inviteToken = token;
|
||||
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Einladungs-Token generiert (gültig 7 Tage)',
|
||||
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
|
||||
res.status(500).json({ success: false, message: 'Serverfehler' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };
|
||||
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ const exportUsers = async (req, res) => {
|
|||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
|
||||
`attachment; filename="stoeberhundefuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
|
||||
return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
|
|||
}));
|
||||
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
|
||||
`attachment; filename="stoeberhundefuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
|
||||
res.json({
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: exportData.length,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "tracking-leaders-backend",
|
||||
"name": "stoeberhunde-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for tracking leaders app",
|
||||
"main": "server.js",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const seedDatabase = async () => {
|
|||
const existingUserCount = await User.countDocuments();
|
||||
if (existingUserCount === 0) {
|
||||
await User.insertMany(users);
|
||||
logger.info(`✅ ${users.length} Nachsuchenführer wurden erstellt`);
|
||||
logger.info(`✅ ${users.length} Stöberhundeführer wurden erstellt`);
|
||||
} else {
|
||||
logger.info(`ℹ️ Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const logger = winston.createLogger({
|
|||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'tracking-leaders-api' },
|
||||
defaultMeta: { service: 'stoeberhunde-api' },
|
||||
transports: [
|
||||
rotateTransport('error', 'error'),
|
||||
rotateTransport('info', 'combined')
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Authorization: Bearer <token>
|
|||
## Öffentliche Endpunkte
|
||||
|
||||
### GET /api/public/users
|
||||
Ruft alle verfügbaren Nachsuchenführer ab.
|
||||
Ruft alle verfügbaren Stöberhundeführer ab.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
|
|
@ -69,7 +69,7 @@ Admin-Login.
|
|||
## Geschützte Endpunkte (Admin)
|
||||
|
||||
### GET /api/users
|
||||
Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
|
||||
Ruft alle Stöberhundeführer ab (auch nicht verfügbare).
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -83,7 +83,7 @@ Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
|
|||
```
|
||||
|
||||
### GET /api/users/:id
|
||||
Ruft einen einzelnen Nachsuchenführer ab.
|
||||
Ruft einen einzelnen Stöberhundeführer ab.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -97,7 +97,7 @@ Ruft einen einzelnen Nachsuchenführer ab.
|
|||
```
|
||||
|
||||
### POST /api/users
|
||||
Erstellt einen neuen Nachsuchenführer.
|
||||
Erstellt einen neuen Stöberhundeführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -126,7 +126,7 @@ Erstellt einen neuen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id
|
||||
Aktualisiert einen Nachsuchenführer.
|
||||
Aktualisiert einen Stöberhundeführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -149,7 +149,7 @@ Aktualisiert einen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### DELETE /api/users/:id
|
||||
Löscht einen Nachsuchenführer.
|
||||
Löscht einen Stöberhundeführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -163,7 +163,7 @@ Löscht einen Nachsuchenführer.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id/availability
|
||||
Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
|
||||
Aktualisiert die Verfügbarkeit eines Stöberhundeführers.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -184,7 +184,7 @@ Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
|
|||
```
|
||||
|
||||
### PUT /api/users/:id/gps
|
||||
Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
|
||||
Aktualisiert die GPS-Koordinaten eines Stöberhundeführers.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
@ -206,7 +206,7 @@ Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
|
|||
```
|
||||
|
||||
### GET /api/users/export
|
||||
Exportiert alle Nachsuchenführer.
|
||||
Exportiert alle Stöberhundeführer.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer <token>`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Übersicht
|
||||
|
||||
Die Nachsuchenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
|
||||
Die Stöberhundeführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
|
||||
|
||||
## Backend-Architektur
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ backend/
|
|||
│ └── validator.js # Input-Validierung
|
||||
├── models/
|
||||
│ ├── Admin.js # Admin-Modell
|
||||
│ └── User.js # Nachsuchenführer-Modell
|
||||
│ └── User.js # Stöberhundeführer-Modell
|
||||
├── routes/
|
||||
│ ├── authRoutes.js # Authentifizierungs-Routes
|
||||
│ └── userRoutes.js # Benutzer-Routes
|
||||
|
|
@ -35,7 +35,7 @@ backend/
|
|||
|
||||
### Datenmodell
|
||||
|
||||
#### User (Nachsuchenführer)
|
||||
#### User (Stöberhundeführer)
|
||||
- `name`: String (erforderlich)
|
||||
- `address`: String (erforderlich)
|
||||
- `phone`: String (erforderlich)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ RUN npm install
|
|||
|
||||
COPY . .
|
||||
|
||||
ARG PUBLIC_URL=/nachsuche/
|
||||
ARG PUBLIC_URL=/stoeberhunde/
|
||||
ENV PUBLIC_URL=$PUBLIC_URL
|
||||
ARG REACT_APP_API_URL=
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "tracking-leaders-frontend",
|
||||
"name": "stoeberhunde-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<meta name="theme-color" content="#2d6a2d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" />
|
||||
<meta name="apple-mobile-web-app-title" content="Stöberhunde Heidekreis" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Nachsuchenstation Heidekreis – Übersicht der Nachsuchenführer"
|
||||
content="Stöberhunde Heidekreis – Übersicht der Stöberhundeführer"
|
||||
/>
|
||||
<title>NSS Heidekreis</title>
|
||||
<title>Stöberhunde Heidekreis</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - Nachsuchenführer</title>
|
||||
<title>Offline - Stöberhundeführer</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #333; }
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
if (result.warning) {
|
||||
showNotification('warning', `Erstellt – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||||
} else {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich erstellt (GPS gesetzt)');
|
||||
showNotification('success', 'Stöberhundeführer erfolgreich erstellt (GPS gesetzt)');
|
||||
}
|
||||
onRefetch();
|
||||
} else {
|
||||
|
|
@ -89,7 +89,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
if (result.warning) {
|
||||
showNotification('warning', `Gespeichert – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||||
} else {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich aktualisiert');
|
||||
showNotification('success', 'Stöberhundeführer erfolgreich aktualisiert');
|
||||
}
|
||||
onRefetch();
|
||||
} else {
|
||||
|
|
@ -100,7 +100,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
const handleUserDelete = async (id) => {
|
||||
const result = await deleteUser(id);
|
||||
if (result.success) {
|
||||
showNotification('success', 'Nachsuchenführer erfolgreich gelöscht');
|
||||
showNotification('success', 'Stöberhundeführer erfolgreich gelöscht');
|
||||
onRefetch();
|
||||
} else {
|
||||
showNotification('error', result.message || 'Fehler beim Löschen');
|
||||
|
|
@ -149,7 +149,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nachsuche-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.download = `stoeberhunde-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification('success', 'Einstellungen exportiert');
|
||||
|
|
@ -366,7 +366,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
className="settings-input"
|
||||
value={config.appName || ''}
|
||||
onChange={(e) => setConfig({ ...config, appName: e.target.value })}
|
||||
placeholder="z.B. Nachsuchenstation Heidekreis"
|
||||
placeholder="z.B. Stöberhunde Heidekreis"
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -423,7 +423,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
{!collapsed.sections && (
|
||||
<div className="settings-section-body">
|
||||
{[
|
||||
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' },
|
||||
{ key: 'ueber-uns', label: 'Stöberhunde Heidekreis' },
|
||||
{ key: 'anwendung', label: 'Anwendung der App' },
|
||||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||||
].map(({ key, label }) => (
|
||||
|
|
@ -504,7 +504,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
|||
<div key={index} className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. HS"
|
||||
placeholder="z.B. SH"
|
||||
value={type.code}
|
||||
onChange={(e) => {
|
||||
const newTypes = [...config.userTypes];
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const Header = ({ isAdmin, onLogout, currentView, onViewChange }) => {
|
|||
className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('handler-login')}
|
||||
>
|
||||
Hundeführer Login
|
||||
Stöberhundeführer-Login
|
||||
</button>
|
||||
<button
|
||||
className={`nav-button ${currentView === 'login' ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
return (
|
||||
<div className="handler-login-container">
|
||||
<div className="handler-login-card">
|
||||
<h2>Hundeführer-Login</h2>
|
||||
<h2>Stöberhundeführer-Login</h2>
|
||||
<p className="handler-login-subtitle">
|
||||
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'tracking-leaders-app/1.0'
|
||||
'User-Agent': 'stoeberhunde-app/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -108,7 +108,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
// Nur verfügbare Nachsuchenführer
|
||||
// Nur verfügbare Stöberhundeführer
|
||||
filtered = filtered.filter(user => user.available);
|
||||
|
||||
// Type filter
|
||||
|
|
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
if (!users || users.length === 0) {
|
||||
return (
|
||||
<div className="no-users">
|
||||
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p>
|
||||
<p>Derzeit sind keine Stöberhundeführer verfügbar.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
return (
|
||||
<div className="public-user-list">
|
||||
<div className="list-header">
|
||||
<h2>Verfügbare Nachsuchenführer</h2>
|
||||
<h2>Verfügbare Stöberhundeführer</h2>
|
||||
<button
|
||||
className="toggle-map-button"
|
||||
onClick={() => setShowMap(!showMap)}
|
||||
|
|
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
)}
|
||||
{filteredAndSortedUsers.length === 0 ? (
|
||||
<div className="no-users">
|
||||
<p>Keine Nachsuchenführer im gewählten Radius gefunden.</p>
|
||||
<p>Keine Stöberhundeführer im gewählten Radius gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-grid">
|
||||
|
|
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
|
|||
</div>
|
||||
)}
|
||||
<h3 className="user-name">{user.name}</h3>
|
||||
<p className="user-role">Nachsuchenführer</p>
|
||||
<p className="user-role">Stöberhundeführer</p>
|
||||
<div className="user-info">
|
||||
<p className="user-address">📍 {user.address}</p>
|
||||
<a href={`tel:${user.phone}`} className="user-phone">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useConfigContext } from '../../contexts/ConfigContext';
|
|||
import './RulesDisplay.css';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' },
|
||||
{ key: 'ueber-uns', label: 'Stöberhunde Heidekreis' },
|
||||
{ key: 'anwendung', label: 'Anwendung der App' },
|
||||
{ key: 'regeln', label: 'Verhaltensregeln' },
|
||||
{ key: 'ansprechpartner', label: 'Ansprechpartner' }
|
||||
|
|
@ -39,7 +39,7 @@ const RulesDisplay = () => {
|
|||
<div className="allgemeines-content">
|
||||
{activeTab === 'ueber-uns' && (
|
||||
<div className="section-text">
|
||||
<h2>Die Nachsuchenstation Heidekreis</h2>
|
||||
<h2>Die Stöberhunde Heidekreis</h2>
|
||||
<div className="section-body">
|
||||
{getSection('ueber-uns').split('\n').map((line, i) => (
|
||||
<p key={i}>{line || '\u00a0'}</p>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
|
||||
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
|
||||
{ headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
setSuggestions(Array.isArray(data) ? data : []);
|
||||
|
|
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
|
||||
return (
|
||||
<form className="user-form" onSubmit={handleSubmit}>
|
||||
<h3>{user ? 'Nachsuchenführer bearbeiten' : 'Neuen Nachsuchenführer erstellen'}</h3>
|
||||
<h3>{user ? 'Stöberhundeführer bearbeiten' : 'Neuen Stöberhundeführer erstellen'}</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name <span className="required">*</span></label>
|
||||
|
|
@ -208,7 +208,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">E-Mail (für Hundeführer-Login)</label>
|
||||
<label htmlFor="email">E-Mail (für Stöberhundeführer-Login)</label>
|
||||
<input type="email" id="email" name="email" value={formData.email} onChange={handleChange}
|
||||
placeholder="optional" />
|
||||
</div>
|
||||
|
|
@ -220,7 +220,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
|||
? typeOptions.map(code => (
|
||||
<option key={code} value={code}>{userTypeLabels[code] || code}</option>
|
||||
))
|
||||
: <option value="HS">Hannoverscher Schweißhund</option>
|
||||
: <option value="SH">Stöberhundleiter</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
}, [users, searchTerm, filters]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="Lädt Nachsuchenführer..." />;
|
||||
return <Loading message="Lädt Stöberhundeführer..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -98,7 +98,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
return (
|
||||
<div className="user-list-admin">
|
||||
<div className="list-header-admin">
|
||||
<h2>Alle Nachsuchenführer</h2>
|
||||
<h2>Alle Stöberhundeführer</h2>
|
||||
<div className="header-actions">
|
||||
<div className="results-count">
|
||||
{filteredAndSortedUsers.length} von {users.length} Führern
|
||||
|
|
@ -111,7 +111,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
+ Neuer Nachsuchenführer
|
||||
+ Neuer Stöberhundeführer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -131,7 +131,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
|||
<FilterPanel filters={filters} onFilterChange={handleFilterChange} />
|
||||
{filteredAndSortedUsers.length === 0 ? (
|
||||
<div className="no-users">
|
||||
<p>Keine Nachsuchenführer gefunden.</p>
|
||||
<p>Keine Stöberhundeführer gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-grid-admin">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ root.render(
|
|||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
navigator.serviceWorker.register('./sw.js')
|
||||
.then(registration => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export const updateGPS = async (id, lat, lng) => {
|
|||
export const exportUsers = async (format = 'csv') => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const filename = `nachsuchenfuehrer_export_${today}.${format}`;
|
||||
const filename = `stoeberhundefuehrer_export_${today}.${format}`;
|
||||
|
||||
const response = await api.get(`/users/export?format=${format}`, {
|
||||
responseType: 'blob'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Use configured API URL when set, otherwise auto-detect common subpath deployment
|
||||
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/stoeberhunde)
|
||||
const detectRuntimeBasePath = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const path = window.location.pathname || '';
|
||||
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
|
||||
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
|
||||
return match || '';
|
||||
if (path === '/stoeberhunde' || path.startsWith('/stoeberhunde/')) {
|
||||
return '/stoeberhunde';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
|
||||
|
|
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
|
|||
: detectRuntimeBasePath();
|
||||
|
||||
export const USER_TYPES = {
|
||||
HS: 'HS',
|
||||
BGS: 'BGS',
|
||||
SB: 'SB',
|
||||
LAB: 'LAB'
|
||||
SH: 'SH',
|
||||
BSH: 'BSH',
|
||||
SHK: 'SHK'
|
||||
};
|
||||
|
||||
export const USER_TYPE_LABELS = {
|
||||
[USER_TYPES.HS]: 'Hannoverscher Schweißhund',
|
||||
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund',
|
||||
[USER_TYPES.SB]: 'Schweißhund (allgemein)',
|
||||
[USER_TYPES.LAB]: 'Labrador'
|
||||
[USER_TYPES.SH]: 'Stöberhundleiter',
|
||||
[USER_TYPES.BSH]: 'Begleithund',
|
||||
[USER_TYPES.SHK]: 'Stöberhund kombiniert'
|
||||
};
|
||||
|
||||
export const RULES = [
|
||||
"1. Verbrechen Sie den Standort und den Anschuss.",
|
||||
"2. Vertreten Sie keine Pirschzeichen.",
|
||||
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
|
||||
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
|
||||
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.",
|
||||
"6. Bei der Nachsuche hat der Hundeführer die Stellung eines Jagdleiters.",
|
||||
"7. Der anfordernde Revierinhaber muss die betroffenen Nachbarreviere verständigen.",
|
||||
"Auf Empfehlung der Jägerschaften Soltau und Fallingbostel ist bei Inanspruchnahme eine pauschale Aufwandsentschädigung an den Nachsuchenführer von 50,00 € zu entrichten."
|
||||
"Informieren Sie den Revierinhaber vor jedem Einsatz.",
|
||||
"Der Stöberhundleiter ist verantwortlich für den sicheren Einsatz seines Hundes.",
|
||||
"Halten Sie die vereinbarten Gebietsabgrenzungen ein.",
|
||||
"Melden Sie das Ergebnis unverzüglich an den Revierinhaber.",
|
||||
"Bei Inanspruchnahme ist eine pauschale Aufwandsentschädigung zu entrichten."
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ server {
|
|||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Support subpath deployment (/nachsuche)
|
||||
location /nachsuche/api/public/ {
|
||||
# Support subpath deployment (/stoeberhunde)
|
||||
location /stoeberhunde/api/public/ {
|
||||
proxy_pass http://backend:5000/api/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -11,7 +11,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nachsuche/api/ {
|
||||
location /stoeberhunde/api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -19,7 +19,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /nachsuche/sw.js {
|
||||
location = /stoeberhunde/sw.js {
|
||||
proxy_pass http://frontend:80/sw.js;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -27,7 +27,7 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nachsuche/ {
|
||||
location /stoeberhunde/ {
|
||||
proxy_pass http://frontend:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@ version: '3.8'
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: tracking-leaders-backend
|
||||
container_name: stoeberhunde-backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://mongo:27017/tracking-leaders
|
||||
- MONGO_URI=mongodb://mongo:27017/stoeberhunde
|
||||
- JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION}
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=http://localhost:8080
|
||||
- GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
- GEOCODE_USER_AGENT=tracking-leaders-app/1.0
|
||||
- GEOCODE_USER_AGENT=stoeberhunde-app/1.0
|
||||
- GEOCODE_MIN_DELAY_MS=1100
|
||||
depends_on:
|
||||
- mongo
|
||||
|
|
@ -29,7 +29,7 @@ services:
|
|||
context: ./frontend
|
||||
args:
|
||||
- REACT_APP_API_URL=http://localhost:5000
|
||||
container_name: tracking-leaders-frontend
|
||||
container_name: stoeberhunde-frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
|
|
@ -43,7 +43,7 @@ services:
|
|||
|
||||
mongo:
|
||||
image: mongo:7.0
|
||||
container_name: tracking-leaders-mongo
|
||||
container_name: stoeberhunde-mongo
|
||||
# Port intentionally NOT exposed externally
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
|
|
|||
Loading…
Reference in New Issue