security: clean repository without media files and sensitive data
- Removed area/ directory with 816MB of media files - Removed sensitive FTP credentials from Git history - Implemented .env.upload system for secure deployments - Added comprehensive .gitignore for future protection This commit represents a clean slate with all sensitive data removed.
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.upload
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/backend/public/static/
|
||||||
|
/frontend/build/
|
||||||
|
/build_temp/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
area/
|
||||||
|
|
||||||
|
# Cache files
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
.env.upload
|
||||||
40
.gitignore-temp
Normal file
40
.gitignore-temp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.upload
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/backend/public/static/
|
||||||
|
/frontend/build/
|
||||||
|
/build_temp/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
area/
|
||||||
|
|
||||||
|
# Cache files
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
.env.upload
|
||||||
228
DEPLOYMENT.md
Normal file
228
DEPLOYMENT.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# 🚀 Deployment Guide
|
||||||
|
|
||||||
|
## 🏠 Lokale Entwicklungsumgebung
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
- **Node.js 16+** und **npm**
|
||||||
|
- **PHP 8.0+** (für lokalen Backend-Server)
|
||||||
|
- **Git** (für Versionskontrolle)
|
||||||
|
|
||||||
|
### Projekt Setup
|
||||||
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
git clone <deine-gitea-url>
|
||||||
|
cd adspreview_react
|
||||||
|
|
||||||
|
# Frontend Dependencies installieren
|
||||||
|
cd frontend/
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokalen Development Server starten
|
||||||
|
```bash
|
||||||
|
# Im frontend/ Ordner
|
||||||
|
npm run start:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Befehl startet **beide Server gleichzeitig**:
|
||||||
|
- **React Development Server**: http://localhost:3000
|
||||||
|
- **PHP Backend Server**: http://localhost:8000
|
||||||
|
|
||||||
|
### Was passiert beim `npm run start:all`?
|
||||||
|
- Startet React App mit Live-Reload auf Port 3000
|
||||||
|
- Startet PHP Built-in Server auf Port 8000 für das Backend
|
||||||
|
- Verwendet `concurrently` um beide Prozesse parallel zu verwalten
|
||||||
|
- React App ist über Proxy mit PHP Backend verbunden
|
||||||
|
|
||||||
|
### Entwicklungsumgebung Features
|
||||||
|
- ✅ **Hot Reload**: Änderungen werden sofort sichtbar
|
||||||
|
- ✅ **API Proxy**: React App kommuniziert automatisch mit PHP Backend
|
||||||
|
- ✅ **Echte Daten**: Verwendet lokale JSON-Dateien als Datenbank
|
||||||
|
- ✅ **Debugging**: Console-Logs und Entwicklertools verfügbar
|
||||||
|
|
||||||
|
### Lokale URLs
|
||||||
|
- **Frontend**: http://localhost:3000 (React App)
|
||||||
|
- **Backend API**: http://localhost:8000/api/* (PHP Endpoints)
|
||||||
|
- **Files**: http://localhost:8000/area/* (Projekt-Dateien)
|
||||||
|
|
||||||
|
### Stoppen der Entwicklungsserver
|
||||||
|
Drücke `Ctrl+C` im Terminal um beide Server zu stoppen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webspace Anforderungen
|
||||||
|
|
||||||
|
### ✅ Minimum Requirements
|
||||||
|
- **PHP 8.3+**
|
||||||
|
- **500 MB+ Speicherplatz** (je nach Projektgröße)
|
||||||
|
- **Schreibrechte** für PHP (für Storage-Ordner)
|
||||||
|
- **URL Rewriting** (mod_rewrite für Apache)
|
||||||
|
|
||||||
|
### 📁 Webspace Struktur
|
||||||
|
```
|
||||||
|
public_html/ (oder htdocs/)
|
||||||
|
├── index.html # React App Entry Point
|
||||||
|
├── static/ # CSS, JS, Media Files
|
||||||
|
├── area/ # Client Project Files
|
||||||
|
│ ├── Paramount/
|
||||||
|
│ ├── Studiocanal/
|
||||||
|
│ └── project_order.json
|
||||||
|
├── api/ # PHP Backend
|
||||||
|
├── storage/ # User Data (needs write permissions!)
|
||||||
|
│ └── data/
|
||||||
|
│ ├── admins.json
|
||||||
|
│ ├── viewers.json
|
||||||
|
│ └── clients.json
|
||||||
|
└── vendor/ # PHP Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Deployment Prozess
|
||||||
|
|
||||||
|
### 1. Setup (einmalig)
|
||||||
|
```bash
|
||||||
|
cd deployment/scripts/
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Production Build erstellen
|
||||||
|
```bash
|
||||||
|
cd deployment/scripts/
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Upload auf Webspace
|
||||||
|
```bash
|
||||||
|
cd deployment/scripts/
|
||||||
|
|
||||||
|
#### Option A: FTP Client (FileZilla, WinSCP, etc.)
|
||||||
|
1. Öffne deinen FTP Client
|
||||||
|
2. Verbinde zu deinem Webspace
|
||||||
|
3. Navigiere zu `public_html/` oder `htdocs/`
|
||||||
|
4. Uploade den kompletten Inhalt von `backend/`
|
||||||
|
|
||||||
|
#### Option B: Command Line (automatisiert)
|
||||||
|
```bash
|
||||||
|
cd scripts/
|
||||||
|
nano upload.sh # Konfiguriere FTP-Zugangsdaten
|
||||||
|
./upload.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Webspace Konfiguration
|
||||||
|
|
||||||
|
#### .htaccess für URL Rewriting
|
||||||
|
Erstelle eine `.htaccess` Datei im Root-Verzeichnis:
|
||||||
|
```apache
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# API Routes zu index.php weiterleiten
|
||||||
|
RewriteCond %{REQUEST_URI} ^/api/
|
||||||
|
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||||
|
|
||||||
|
# React Router: Alle anderen Requests zu index.html
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . index.html [L]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ordner-Berechtigungen setzen
|
||||||
|
```bash
|
||||||
|
# Via FTP Client oder SSH
|
||||||
|
chmod 755 storage/
|
||||||
|
chmod 755 storage/data/
|
||||||
|
chmod 644 storage/data/*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Sicherheit
|
||||||
|
|
||||||
|
### Sensible Dateien schützen
|
||||||
|
Erstelle `.htaccess` in `storage/`:
|
||||||
|
```apache
|
||||||
|
# Zugriff auf Storage-Dateien verbieten
|
||||||
|
<Files "*.json">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</Files>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variablen (optional)
|
||||||
|
Erstelle `.env` im Backend-Root:
|
||||||
|
```
|
||||||
|
BACKEND_URL=https://deine-domain.de
|
||||||
|
JWT_SECRET=dein-super-sicherer-schlüssel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Live-Betrieb
|
||||||
|
|
||||||
|
### Domain Setup
|
||||||
|
- **Subdomain**: `adspreview.deine-domain.de`
|
||||||
|
- **Hauptdomain**: `deine-domain.de/adspreview/`
|
||||||
|
|
||||||
|
### SSL/HTTPS
|
||||||
|
- Aktiviere SSL in deinem Webspace-Panel
|
||||||
|
- Moderne Browser benötigen HTTPS für JWT-Tokens
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
```bash
|
||||||
|
# Regelmäßige Backups der User-Daten
|
||||||
|
tar -czf backup_$(date +%Y%m%d).tar.gz storage/data/ area/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
### Häufige Probleme:
|
||||||
|
|
||||||
|
1. **"404 Not Found" bei API-Calls**
|
||||||
|
→ Prüfe URL Rewriting (.htaccess)
|
||||||
|
|
||||||
|
2. **"Permission denied" beim Login**
|
||||||
|
→ Prüfe Schreibrechte auf storage/
|
||||||
|
|
||||||
|
3. **React App lädt nicht**
|
||||||
|
→ Prüfe BACKEND_URL in der Konfiguration
|
||||||
|
|
||||||
|
4. **Uploads funktionieren nicht**
|
||||||
|
→ Prüfe PHP upload_max_filesize und post_max_size
|
||||||
|
|
||||||
|
### Debug-Modus aktivieren
|
||||||
|
In `backend/public/index.php`:
|
||||||
|
```php
|
||||||
|
// Temporär für Debugging
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Performance Optimierung
|
||||||
|
|
||||||
|
### Gzip Compression
|
||||||
|
In `.htaccess`:
|
||||||
|
```apache
|
||||||
|
# Komprimierung aktivieren
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json
|
||||||
|
</IfModule>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching Headers
|
||||||
|
```apache
|
||||||
|
# Browser-Caching für statische Dateien
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive on
|
||||||
|
ExpiresByType text/css "access plus 1 month"
|
||||||
|
ExpiresByType application/javascript "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/jpg "access plus 1 month"
|
||||||
|
</IfModule>
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] PHP 8.3+ verfügbar
|
||||||
|
- [ ] Skripte ausgeführt (`setup.sh` → `deploy.sh`)
|
||||||
|
- [ ] Backend-Ordner hochgeladen
|
||||||
|
- [ ] .htaccess konfiguriert
|
||||||
|
- [ ] Berechtigungen gesetzt
|
||||||
|
- [ ] SSL aktiviert
|
||||||
|
- [ ] Admin-User angelegt
|
||||||
|
- [ ] Test-Login erfolgreich
|
||||||
|
|
||||||
|
**Anwendung ist live! 🎉**
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
(apply,CacheStats{hitCount=17, missCount=14, loadSuccessCount=14, loadExceptionCount=0, totalLoadTime=4444978210, evictionCount=0})
|
||||||
|
(tree,CacheStats{hitCount=18, missCount=585, loadSuccessCount=554, loadExceptionCount=0, totalLoadTime=7468960507, evictionCount=0})
|
||||||
|
(commit,CacheStats{hitCount=6, missCount=7, loadSuccessCount=7, loadExceptionCount=0, totalLoadTime=567782542, evictionCount=0})
|
||||||
|
(tag,CacheStats{hitCount=0, missCount=0, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fb91d2f9bbdc5d37217fe0c09be4713514515eca c06c8e9aa26a5e2bfc25bf3b4995b2b04ba68b1d server.txt
|
||||||
|
7466996e82872e83db22590f6682ee4e0cd02c70 e6a7b552b6e6a65260b43f7e8bfe2d353c6e7c8b server_backup.txt
|
||||||
|
0818c194d21a7187f40079014d35eb9002486f18 6d3e3054071ab8691bb1a19d70762e7a9f23650d server_working_partly.txt
|
||||||
|
9a02a4aed4f87e38d877511d6fe7d8df254c0cbd 58315c207f4d3379e3f638d396f495a443e96f65 upload.sh
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
0eedb990cf6e47c1bd9a6ee27d38e61093db4122 cec05d0919edf8a469de3d1d825d24ea8f7f82ff
|
||||||
|
1341c662138a88b42ff9b2e67f361d6e64114e72 f544e2074298e881b0e80d2e2a464548bf9a66b0
|
||||||
|
1cde4d0b22533a0c673f3e4a35184aa50a76990b 7b8e13d76c3d1d4a663a1e3965d68ad36d4a5686
|
||||||
|
25e309a0fc1438496972eb8d431cb47f2b077783 392f3ffacf5c410d10c713a3794baad5c60299a8
|
||||||
|
30ace6cd8f71848b82b666473698a03f3102c346 3091f3ce2e6db63d6b064b4249578049d13abcd7
|
||||||
|
424ef9061dcec9cb8c028c1a9fff4b538e402e68 2a9adb742a2dd42edf0b25ade4a90b866365c0a6
|
||||||
|
5a68286a243579bb85fd14fbf133ee5403691285 20b66d360f056b1db42df69887b01a45c90a3ab0
|
||||||
|
7a5d9fe6afad98dd5d38c367f3c6af2a1095fb6b e011d5b3cd3dd568d9d88116d49a0357cd6fea03
|
||||||
|
a7b9708fb7189f208c118542cbd806ed74b444d8 7495b7c1db71f839345fd792d5fcb5927587c435
|
||||||
|
d056c48b93fa218e08229f8198b1af8241f0f7b6 1dbc47a4499966ca4f320b07965e5a0f44e7f6d1
|
||||||
|
d631fdc8e04b573eda04c877a96ebdb2f28a2db5 eacedf125866d78e85df3a7ddc2e0e23da1b39e2
|
||||||
|
f381b65f0fe38f35531cfa9a83c3b06f0f3c0d6b ff2d7928b74714d6c5e2d1eb9fccdc8dd942b8f9
|
||||||
|
f99c729712a7235166507eb36ec1851f8453a0ac 72dd450b1f30d5695cfbba5487969f1d4b2b1dea
|
||||||
27
backend/public/.htaccess
Executable file
27
backend/public/.htaccess
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# API Routes zu index.php weiterleiten
|
||||||
|
RewriteCond %{REQUEST_URI} ^/api/
|
||||||
|
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||||
|
|
||||||
|
# React Router: Alle anderen Requests zu index.html (außer existierende Dateien)
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} !^/area/
|
||||||
|
RewriteRule . index.html [L]
|
||||||
|
|
||||||
|
# Optional: Gzip Komprimierung
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Optional: Browser Caching
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive on
|
||||||
|
ExpiresByType text/css "access plus 1 month"
|
||||||
|
ExpiresByType application/javascript "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/jpg "access plus 1 month"
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/webp "access plus 1 month"
|
||||||
|
</IfModule>
|
||||||
46
backend/public/.nginx
Normal file
46
backend/public/.nginx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# nginx Konfiguration für React + PHP Backend
|
||||||
|
# Diese Datei sollte als .nginx im public/ Verzeichnis liegen
|
||||||
|
|
||||||
|
# API-Routen an PHP weiterleiten
|
||||||
|
location /api/ {
|
||||||
|
try_files $uri /index.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Area-Dateien an PHP weiterleiten (für statische Asset-Auslieferung)
|
||||||
|
location /area/ {
|
||||||
|
try_files $uri /index.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statische React Assets direkt ausliefern
|
||||||
|
location /static/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# React Assets (JSON, ICO, etc.)
|
||||||
|
location ~* \.(json|ico|txt)$ {
|
||||||
|
expires 1d;
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP-Dateien verarbeiten
|
||||||
|
location ~ \.php$ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_intercept_errors on;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_read_timeout 3600;
|
||||||
|
fastcgi_send_timeout 3600;
|
||||||
|
fastcgi_param HTTPS "on";
|
||||||
|
fastcgi_param SERVER_PORT 443;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# React SPA Fallback - alle anderen Routen auf index.html weiterleiten
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
13
backend/public/asset-manifest.json
Executable file
13
backend/public/asset-manifest.json
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": {
|
||||||
|
"main.css": "/static/css/main.7b221a61.css",
|
||||||
|
"main.js": "/static/js/main.f30d7548.js",
|
||||||
|
"index.html": "/index.html",
|
||||||
|
"main.7b221a61.css.map": "/static/css/main.7b221a61.css.map",
|
||||||
|
"main.f30d7548.js.map": "/static/js/main.f30d7548.js.map"
|
||||||
|
},
|
||||||
|
"entrypoints": [
|
||||||
|
"static/css/main.7b221a61.css",
|
||||||
|
"static/js/main.f30d7548.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
backend/public/files.php
Normal file
17
backend/public/files.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
$dir = __DIR__ . '/../area/Paramount/Testprojekt/ads';
|
||||||
|
$rootPath = realpath($dir);
|
||||||
|
if (is_dir($dir) && is_readable($dir)) {
|
||||||
|
$files = scandir($dir);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'files' => $files,
|
||||||
|
'rootPath' => $rootPath
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ordner nicht vorhanden oder keine Leserechte',
|
||||||
|
'rootPath' => $rootPath
|
||||||
|
]);
|
||||||
|
}
|
||||||
1
backend/public/index.html
Normal file
1
backend/public/index.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="de"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>adspreview</title><script defer="defer" src="/static/js/main.f30d7548.js"></script><link href="/static/css/main.7b221a61.css" rel="stylesheet"></head><body><div id="root"></div></body></html>
|
||||||
33
backend/public/index.php
Executable file
33
backend/public/index.php
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// CORS-Header für alle API-Routen setzen
|
||||||
|
if (preg_match('#^/api/#', $_SERVER['REQUEST_URI'])) {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Statische Auslieferung von /area/...
|
||||||
|
if (preg_match('#^/area/#', $_SERVER['REQUEST_URI'])) {
|
||||||
|
$relPath = str_replace('/area', '', parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||||
|
// URL-decode für Sonderzeichen wie +
|
||||||
|
$relPath = urldecode($relPath);
|
||||||
|
$file = realpath(__DIR__ . '/../../area' . $relPath);
|
||||||
|
$base = realpath(__DIR__ . '/../../area');
|
||||||
|
|
||||||
|
if ($file && is_file($file) && strpos($file, $base) === 0) {
|
||||||
|
$mime = mime_content_type($file);
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
readfile($file);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einstiegspunkt für die PHP-API
|
||||||
|
require_once __DIR__ . '/../src/Core/Application.php';
|
||||||
|
|
||||||
|
$app = new Application();
|
||||||
|
$app->run();
|
||||||
2
backend/public/static/css/main.7b221a61.css
Normal file
2
backend/public/static/css/main.7b221a61.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
body,html{height:100%;width:100%}input::-ms-clear,input::-ms-reveal{display:none}*,:after,:before{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0);font-family:sans-serif;line-height:1.15}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:initial;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{font-weight:500;margin-bottom:.5em;margin-top:0}p{margin-bottom:1em;margin-top:0}abbr[data-original-title],abbr[title]{border-bottom:0;cursor:help;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}address{font-style:normal;line-height:inherit;margin-bottom:1em}input[type=number],input[type=password],input[type=text],textarea{-webkit-appearance:none}dl,ol,ul{margin-bottom:1em;margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}code,kbd,pre,samp{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;font-size:1em}pre{margin-bottom:1em;margin-top:0;overflow:auto}figure{margin:0 0 1em}img{border-style:none;vertical-align:middle}[role=button],a,area,button,input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{caption-side:bottom;padding-bottom:.3em;padding-top:.75em;text-align:left}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{color:inherit;display:block;font-size:1.5em;line-height:inherit;margin-bottom:.5em;max-width:100%;padding:0;white-space:normal;width:100%}progress{vertical-align:initial}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:none;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{background-color:#feffe6;padding:.2em}.ant-tabs{border-radius:0;padding:0}.ant-tabs>.ant-tabs-nav{margin-bottom:0;padding:9px 32px;position:sticky;top:0;z-index:100}.ant-tabs-content{overflow:auto;padding:0 32px}
|
||||||
|
/*# sourceMappingURL=main.7b221a61.css.map*/
|
||||||
1
backend/public/static/css/main.7b221a61.css.map
Executable file
1
backend/public/static/css/main.7b221a61.css.map
Executable file
File diff suppressed because one or more lines are too long
3
backend/public/static/js/main.f30d7548.js
Normal file
3
backend/public/static/js/main.f30d7548.js
Normal file
File diff suppressed because one or more lines are too long
157
backend/public/static/js/main.f30d7548.js.LICENSE.txt
Executable file
157
backend/public/static/js/main.f30d7548.js.LICENSE.txt
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
/*!
|
||||||
|
Copyright (c) 2018 Jed Watson.
|
||||||
|
Licensed under the MIT License (MIT), see
|
||||||
|
http://jedwatson.github.io/classnames
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-is.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-jsx-runtime.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* scheduler.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @remix-run/router v1.19.2
|
||||||
|
*
|
||||||
|
* Copyright (c) Remix Software Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE.md file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Router DOM v6.26.2
|
||||||
|
*
|
||||||
|
* Copyright (c) Remix Software Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE.md file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Router v6.26.2
|
||||||
|
*
|
||||||
|
* Copyright (c) Remix Software Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE.md file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**!
|
||||||
|
* Sortable 1.15.6
|
||||||
|
* @author RubaXa <trash@rubaxa.org>
|
||||||
|
* @author owenm <owen23355@gmail.com>
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
1
backend/public/static/js/main.f30d7548.js.map
Executable file
1
backend/public/static/js/main.f30d7548.js.map
Executable file
File diff suppressed because one or more lines are too long
30
backend/src/Api/AuthController.php
Normal file
30
backend/src/Api/AuthController.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
|
||||||
|
class AuthController {
|
||||||
|
public static function login() {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$username = $input['username'] ?? null;
|
||||||
|
$password = $input['password'] ?? null;
|
||||||
|
|
||||||
|
$result = AuthService::authenticate($username, $password);
|
||||||
|
if ($result['success']) {
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'token' => $result['token'],
|
||||||
|
'role' => $result['role'],
|
||||||
|
'message' => 'Login erfolgreich.'
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'AUTH_FAILED',
|
||||||
|
'message' => $result['message']
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
backend/src/Api/EntityController.php
Normal file
197
backend/src/Api/EntityController.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
require_once __DIR__ . '/../Services/JsonStorageService.php';
|
||||||
|
|
||||||
|
class EntityController {
|
||||||
|
private static function authorize(string $requiredRole) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
if (!$user || strtolower($user['role']) !== strtolower($requiredRole)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => ['code'=>'FORBIDDEN','message'=>'Nicht berechtigt.']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function list(string $type) {
|
||||||
|
$user = self::authorize('admin');
|
||||||
|
|
||||||
|
// Datei-Namen-Mapping: 'users' speichert in admins.json
|
||||||
|
$storageType = $type === 'users' ? 'admins' : $type;
|
||||||
|
$items = JsonStorageService::read($storageType);
|
||||||
|
|
||||||
|
// Für Client-Listen: Prüfe disallowedClients des aktuellen Admins
|
||||||
|
if ($type === 'clients') {
|
||||||
|
$adminData = self::getAdminData($user['username']);
|
||||||
|
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
||||||
|
|
||||||
|
// Filtere nicht erlaubte Clients aus
|
||||||
|
$filteredItems = [];
|
||||||
|
foreach ($items as $clientKey => $clientData) {
|
||||||
|
if (!in_array($clientKey, $disallowedClients)) {
|
||||||
|
$filteredItems[$clientKey] = $clientData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$items = $filteredItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für User-Listen: Entferne Passwörter aus der Response
|
||||||
|
if ($type === 'users') {
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
unset($item['password']); // Passwörter nie an Frontend senden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode(['success'=>true, strtolower($type)=>$items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Admin-Daten laden
|
||||||
|
private static function getAdminData($username) {
|
||||||
|
$adminFile = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
if (file_exists($adminFile)) {
|
||||||
|
$admins = json_decode(file_get_contents($adminFile), true);
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($admin['username'] === $username) {
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(string $type) {
|
||||||
|
self::authorize('admin');
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input) || empty($input['username']) || empty($input['role'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'INVALID_INPUT','message'=>'Ungültige Eingabedaten.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$storageType = $type === 'users' ? 'admins' : $type;
|
||||||
|
$items = JsonStorageService::read($storageType);
|
||||||
|
$newId = empty($items) ? 1 : max(array_column($items,'id'))+1;
|
||||||
|
$new = [
|
||||||
|
'id'=>$newId,
|
||||||
|
'username'=>$input['username'],
|
||||||
|
'role'=>$input['role'],
|
||||||
|
'email'=>$input['email']??''
|
||||||
|
];
|
||||||
|
|
||||||
|
// Passwort hashen falls angegeben
|
||||||
|
if (!empty($input['password'])) {
|
||||||
|
$new['password'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// disallowedClients für Admin-Benutzer hinzufügen
|
||||||
|
if ($input['role'] === 'admin' && isset($input['disallowedClients'])) {
|
||||||
|
$new['disallowedClients'] = is_array($input['disallowedClients']) ? $input['disallowedClients'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $new;
|
||||||
|
JsonStorageService::write($storageType, $items);
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode(['success'=>true, strtolower($type)=>$new]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(string $type, int $id) {
|
||||||
|
self::authorize('admin');
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input) || empty($input['username']) || empty($input['role'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'INVALID_INPUT','message'=>'Ungültige Eingabedaten.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$storageType = $type === 'users' ? 'admins' : $type;
|
||||||
|
$items = JsonStorageService::read($storageType);
|
||||||
|
$found = false;
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
if ($item['id']==$id) {
|
||||||
|
$item['username']=$input['username'];
|
||||||
|
$item['role']=$input['role'];
|
||||||
|
$item['email']=$input['email']??'';
|
||||||
|
|
||||||
|
// Passwort aktualisieren falls angegeben
|
||||||
|
if (!empty($input['password'])) {
|
||||||
|
$item['password'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// disallowedClients für Admin-Benutzer aktualisieren
|
||||||
|
if ($input['role'] === 'admin' && isset($input['disallowedClients'])) {
|
||||||
|
$item['disallowedClients'] = is_array($input['disallowedClients']) ? $input['disallowedClients'] : [];
|
||||||
|
} elseif ($input['role'] !== 'admin') {
|
||||||
|
// Entferne disallowedClients wenn User kein Admin mehr ist
|
||||||
|
unset($item['disallowedClients']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'NOT_FOUND','message'=>'Eintrag nicht gefunden.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonStorageService::write($storageType, $items);
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode(['success'=>true,strtolower($type)=>$item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(string $type, int $id) {
|
||||||
|
self::authorize('admin');
|
||||||
|
$storageType = $type === 'users' ? 'admins' : $type;
|
||||||
|
$items = JsonStorageService::read($storageType);
|
||||||
|
$found = false;
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item['id']==$id) { $found=true; continue; }
|
||||||
|
$out[]=$item;
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'NOT_FOUND','message'=>'Eintrag nicht gefunden.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonStorageService::write($storageType, $out);
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode(['success'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updatePassword(string $type, int $id) {
|
||||||
|
self::authorize('admin');
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($input) || empty($input['password'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'INVALID_INPUT','message'=>'Passwort ist erforderlich.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storageType = $type === 'users' ? 'admins' : $type;
|
||||||
|
$items = JsonStorageService::read($storageType);
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
if ($item['id']==$id) {
|
||||||
|
$item['password'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success'=>false,'error'=>['code'=>'NOT_FOUND','message'=>'Benutzer nicht gefunden.']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonStorageService::write($storageType, $items);
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode(['success'=>true, 'message'=>'Passwort erfolgreich aktualisiert.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
792
backend/src/Api/ProjectsController.php
Normal file
792
backend/src/Api/ProjectsController.php
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../vendor/getid3/getid3.php';
|
||||||
|
|
||||||
|
class ProjectsController {
|
||||||
|
|
||||||
|
// Bestimmt den korrekten Pfad zum area-Ordner basierend auf der Umgebung
|
||||||
|
private static function getAreaPath() {
|
||||||
|
// Prüfe ob wir in der Development-Struktur sind (backend/src/Api)
|
||||||
|
$devPath = __DIR__ . '/../../../area';
|
||||||
|
if (is_dir($devPath)) {
|
||||||
|
return '/../../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob wir in der Deployment-Struktur sind (src/Api)
|
||||||
|
$deployPath = __DIR__ . '/../../area';
|
||||||
|
if (is_dir($deployPath)) {
|
||||||
|
return '/../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback auf Development-Pfad
|
||||||
|
return '/../../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zentrale Liste der zu ignorierenden Dateien
|
||||||
|
private static function getIgnoredFiles() {
|
||||||
|
return [
|
||||||
|
'.DS_Store', // macOS System-Datei
|
||||||
|
'Thumbs.db', // Windows Thumbnail-Cache
|
||||||
|
'desktop.ini', // Windows Desktop-Konfiguration
|
||||||
|
'.gitignore', // Git-Konfiguration
|
||||||
|
'.gitkeep', // Git-Platzhalter
|
||||||
|
'config.yaml', // Konfigurationsdateien
|
||||||
|
'config.yml',
|
||||||
|
'setup.yaml',
|
||||||
|
'setup.yml',
|
||||||
|
'.htaccess', // Apache-Konfiguration
|
||||||
|
'index.php', // PHP-Index (außer in HTML-Ordnern)
|
||||||
|
'web.config', // IIS-Konfiguration
|
||||||
|
'.env', // Environment-Variablen
|
||||||
|
'.env.local',
|
||||||
|
'README.md', // Dokumentation
|
||||||
|
'readme.txt',
|
||||||
|
'license.txt',
|
||||||
|
'LICENSE'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft, ob der eingeloggte Client auf den gewünschten Bereich zugreifen darf
|
||||||
|
private static function requireClientAccess($clientDir) {
|
||||||
|
$user = self::requireAuth();
|
||||||
|
// Admins haben grundsätzlich Zugriff, aber prüfe disallowedClients
|
||||||
|
if (isset($user['role']) && $user['role'] === 'admin') {
|
||||||
|
$adminData = self::getAdminData($user['username']);
|
||||||
|
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
||||||
|
|
||||||
|
if (in_array($clientDir, $disallowedClients)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => "Zugriff auf Client '{$clientDir}' ist für diesen Administrator nicht erlaubt."
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
if ($user['role'] === 'client' && $user['dir'] !== $clientDir) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt für diesen Bereich.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT-basierte Authentifizierungsprüfung für alle API-Methoden
|
||||||
|
private static function requireAuth() {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? null;
|
||||||
|
if (!$authHeader || !preg_match('/^Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'UNAUTHORIZED',
|
||||||
|
'message' => 'Kein gültiges Auth-Token übergeben.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$jwt = $matches[1];
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
$user = AuthService::verifyJWT($jwt);
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'UNAUTHORIZED',
|
||||||
|
'message' => 'Token ungültig oder abgelaufen.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Optional: User-Infos für spätere Nutzung zurückgeben
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob eine Datei ignoriert werden soll
|
||||||
|
private static function shouldIgnoreFile($filename) {
|
||||||
|
$ignoredFiles = self::getIgnoredFiles();
|
||||||
|
return in_array(strtolower($filename), array_map('strtolower', $ignoredFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function listForClient($clientDir) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$base = realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir);
|
||||||
|
// Lade clients.json, um den Anzeigenamen des Clients zu bekommen
|
||||||
|
$loginsFile = __DIR__ . '/../../../storage/data/clients.json';
|
||||||
|
$clientDisplay = $clientDir;
|
||||||
|
if (file_exists($loginsFile)) {
|
||||||
|
$logins = json_decode(file_get_contents($loginsFile), true);
|
||||||
|
foreach ($logins as $login) {
|
||||||
|
if (isset($login['dir']) && $login['dir'] === $clientDir) {
|
||||||
|
$clientDisplay = $login['dir'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$base || !is_dir($base)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Kundenordner nicht gefunden.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade project_order.json falls vorhanden
|
||||||
|
$projectOrderFile = $base . '/project_order.json';
|
||||||
|
$projectOrder = [];
|
||||||
|
if (file_exists($projectOrderFile)) {
|
||||||
|
$orderContent = file_get_contents($projectOrderFile);
|
||||||
|
$decoded = json_decode($orderContent, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$projectOrder = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects = [];
|
||||||
|
$foundProjects = [];
|
||||||
|
|
||||||
|
// Sammle alle verfügbaren Projekte
|
||||||
|
foreach (scandir($base) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
$path = $base . '/' . $entry;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$setupDir = $path . '/setup';
|
||||||
|
$poster = null;
|
||||||
|
$logo = null;
|
||||||
|
if (is_dir($setupDir)) {
|
||||||
|
foreach (scandir($setupDir) as $file) {
|
||||||
|
if (!$poster && stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if ($poster && $logo) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$isHE = (stripos($entry, 'HE_') === 0);
|
||||||
|
$foundProjects[$entry] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'path' => $entry,
|
||||||
|
'poster' => $poster,
|
||||||
|
'logo' => $logo,
|
||||||
|
'isHE' => $isHE,
|
||||||
|
'client' => $clientDisplay
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortiere nach project_order.json falls vorhanden
|
||||||
|
if (!empty($projectOrder)) {
|
||||||
|
// Erst die Projekte in der definierten Reihenfolge
|
||||||
|
foreach ($projectOrder as $orderedProject) {
|
||||||
|
if (isset($foundProjects[$orderedProject])) {
|
||||||
|
$projects[] = $foundProjects[$orderedProject];
|
||||||
|
unset($foundProjects[$orderedProject]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neue Projekte (nicht in order-Liste) kommen an den ANFANG
|
||||||
|
$remainingProjects = array_values($foundProjects);
|
||||||
|
usort($remainingProjects, function($a, $b) {
|
||||||
|
return strcmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
// Neue Projekte VOR die sortierten einfügen
|
||||||
|
$projects = array_merge($remainingProjects, $projects);
|
||||||
|
} else {
|
||||||
|
// Fallback: Alphabetische Sortierung
|
||||||
|
$projects = array_values($foundProjects);
|
||||||
|
usort($projects, function($a, $b) {
|
||||||
|
return strcmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'projects' => $projects
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichert die Projekt-Reihenfolge in project_order.json
|
||||||
|
public static function saveProjectOrder($clientDir) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
|
||||||
|
// JSON-Body lesen
|
||||||
|
$input = file_get_contents('php://input');
|
||||||
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
|
if (!isset($data['order']) || !is_array($data['order'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_DATA',
|
||||||
|
'message' => 'Ungültige Daten. "order" Array erwartet.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir);
|
||||||
|
if (!$base || !is_dir($base)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Kundenordner nicht gefunden.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectOrderFile = $base . '/project_order.json';
|
||||||
|
|
||||||
|
// Validiere dass alle Projekte im order Array auch wirklich existieren
|
||||||
|
$existingProjects = [];
|
||||||
|
foreach (scandir($base) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (is_dir($base . '/' . $entry)) {
|
||||||
|
$existingProjects[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['order'] as $projectName) {
|
||||||
|
if (!in_array($projectName, $existingProjects)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_PROJECT',
|
||||||
|
'message' => "Projekt '$projectName' existiert nicht."
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichere die neue Reihenfolge
|
||||||
|
$result = file_put_contents($projectOrderFile, json_encode($data['order'], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'WRITE_ERROR',
|
||||||
|
'message' => 'Fehler beim Speichern der project_order.json.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Projekt-Reihenfolge gespeichert.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adsFolders($clientDir, $projectName) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
$projectName = rawurldecode($projectName);
|
||||||
|
$adsPath = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
|
$folders = [];
|
||||||
|
if (is_dir($adsPath)) {
|
||||||
|
foreach (scandir($adsPath) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (is_dir($adsPath . '/' . $entry)) {
|
||||||
|
$folders[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['folders' => $folders]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adsSubfolders($clientDir, $projectName, ...$adsFolders) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
|
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
|
if (!empty($adsFolders)) {
|
||||||
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
|
}
|
||||||
|
$real = realpath($base);
|
||||||
|
$folders = [];
|
||||||
|
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
||||||
|
foreach (scandir($real) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (is_dir($real . '/' . $entry)) {
|
||||||
|
$folders[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['folders' => $folders]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert die Sub-Subfolder eines Subfolders in ads
|
||||||
|
public static function adsSubSubfolders($clientDir, $projectName, $adsFolder, $subFolder) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$adsFolder = rawurldecode($adsFolder);
|
||||||
|
$subFolder = rawurldecode($subFolder);
|
||||||
|
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads/' . $adsFolder . '/' . $subFolder;
|
||||||
|
$real = realpath($base);
|
||||||
|
$folders = [];
|
||||||
|
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
||||||
|
foreach (scandir($real) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (is_dir($real . '/' . $entry)) {
|
||||||
|
$folders[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['folders' => $folders]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert die Dateien eines beliebigen Unterordners in ads (beliebige Tiefe)
|
||||||
|
public static function adsFolderFiles($clientDir, $projectName, ...$adsFolders) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
|
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
|
if (!empty($adsFolders)) {
|
||||||
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
|
}
|
||||||
|
$real = realpath($base);
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
||||||
|
foreach (scandir($real) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
$full = $real . '/' . $entry;
|
||||||
|
if (is_file($full)) {
|
||||||
|
// Ignoriere bestimmte Dateien
|
||||||
|
if (self::shouldIgnoreFile($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
||||||
|
$type = 'other';
|
||||||
|
if (in_array($ext, ['jpg','jpeg','png','gif','webp','svg'])) $type = 'image';
|
||||||
|
elseif (in_array($ext, ['mp4','mov','avi','webm'])) $type = 'video';
|
||||||
|
elseif (in_array($ext, ['html','htm'])) $type = 'html';
|
||||||
|
$urlParts = array_map('rawurlencode', $adsFolders);
|
||||||
|
$files[] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'type' => $type,
|
||||||
|
'url' => "/area/$clientDir/$projectName/ads/" . implode('/', $urlParts) . (count($urlParts) ? '/' : '') . rawurlencode($entry)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['files' => $files]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert sowohl Ordner als auch Dateien eines beliebigen Unterordners in ads (beliebige Tiefe)
|
||||||
|
public static function adsFolderContent($clientDir, $projectName, ...$adsFolders) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
|
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
|
if (!empty($adsFolders)) {
|
||||||
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
|
}
|
||||||
|
$real = realpath($base);
|
||||||
|
$folders = [];
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
||||||
|
foreach (scandir($real) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
$full = $real . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_dir($full)) {
|
||||||
|
$folders[] = $entry;
|
||||||
|
} elseif (is_file($full)) {
|
||||||
|
// Ignoriere bestimmte Dateien
|
||||||
|
if (self::shouldIgnoreFile($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
||||||
|
$type = 'other';
|
||||||
|
if (in_array($ext, ['jpg','jpeg','png','gif','webp','svg'])) $type = 'image';
|
||||||
|
elseif (in_array($ext, ['mp4','mov','avi','webm'])) $type = 'video';
|
||||||
|
elseif (in_array($ext, ['html','htm'])) $type = 'html';
|
||||||
|
$urlParts = array_map('rawurlencode', $adsFolders);
|
||||||
|
$files[] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'type' => $type,
|
||||||
|
'url' => "/area/$clientDir/$projectName/ads/" . implode('/', $urlParts) . (count($urlParts) ? '/' : '') . rawurlencode($entry)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'folders' => $folders,
|
||||||
|
'files' => $files
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert config.yaml aus einem beliebigen Ordnerpfad
|
||||||
|
public static function getConfig($clientDir, $projectName, ...$adsFolders) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
|
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
|
if (!empty($adsFolders)) {
|
||||||
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
|
}
|
||||||
|
$real = realpath($base);
|
||||||
|
|
||||||
|
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
||||||
|
$configFile = $real . '/config.yaml';
|
||||||
|
if (file_exists($configFile)) {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
readfile($configFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'config.yaml nicht gefunden'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert die Details eines Projekts (inkl. Logo, Poster etc.)
|
||||||
|
public static function projectDetails($clientDir, $projectName) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
$projectName = rawurldecode($projectName);
|
||||||
|
|
||||||
|
$setupDir = __DIR__ . self::getAreaPath() . "/$clientDir/$projectName/setup";
|
||||||
|
$poster = null;
|
||||||
|
$logo = null;
|
||||||
|
|
||||||
|
if (is_dir($setupDir)) {
|
||||||
|
foreach (scandir($setupDir) as $file) {
|
||||||
|
if ($file === '.' || $file === '..') continue;
|
||||||
|
if (stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if ($poster && $logo) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'poster' => $poster,
|
||||||
|
'logo' => $logo,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekursive Übersicht aller Ads als strukturierter Baum
|
||||||
|
public static function adsOverview($clientDir, $projectName) {
|
||||||
|
self::requireClientAccess($clientDir);
|
||||||
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
$projectName = rawurldecode($projectName);
|
||||||
|
$adsRoot = __DIR__ . self::getAreaPath() . "/$clientDir/$projectName/ads";
|
||||||
|
$baseUrl = $_ENV['BACKEND_URL'] ?? (isset($_SERVER['HTTP_HOST']) ? (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] : '');
|
||||||
|
|
||||||
|
// Hilfsfunktion für schöne Tab-Titel
|
||||||
|
function beautifyTabName($name) {
|
||||||
|
// Ersetze _ und - durch Leerzeichen, dann jedes Wort groß
|
||||||
|
$name = str_replace(['_', '-'], ' ', $name);
|
||||||
|
$name = mb_strtolower($name, 'UTF-8');
|
||||||
|
$name = preg_replace_callback('/\b\w/u', function($m) { return mb_strtoupper($m[0], 'UTF-8'); }, $name);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanAdsFolder($absPath, $relPath, $baseUrl, $clientDir, $projectName) {
|
||||||
|
$result = [];
|
||||||
|
if (!is_dir($absPath)) return $result;
|
||||||
|
$entries = array_diff(scandir($absPath), ['.', '..']);
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$entryAbs = "$absPath/$entry";
|
||||||
|
$entryRel = $relPath ? "$relPath/$entry" : $entry;
|
||||||
|
if (is_dir($entryAbs)) {
|
||||||
|
$children = scanAdsFolder($entryAbs, $entryRel, $baseUrl, $clientDir, $projectName);
|
||||||
|
$depth = substr_count($entryRel, '/');
|
||||||
|
$type = match($depth) {
|
||||||
|
0 => 'category',
|
||||||
|
1 => 'subcategory',
|
||||||
|
2 => 'ad',
|
||||||
|
default => 'folder'
|
||||||
|
};
|
||||||
|
$node = [
|
||||||
|
'name' => $entry,
|
||||||
|
'title' => beautifyTabName($entry),
|
||||||
|
'type' => $type,
|
||||||
|
];
|
||||||
|
if ($type === 'ad') {
|
||||||
|
// Prüfe, ob ein Unterordner mit 'html' im Namen existiert
|
||||||
|
$htmlFolder = null;
|
||||||
|
foreach (scandir($entryAbs) as $sub) {
|
||||||
|
if ($sub === '.' || $sub === '..') continue;
|
||||||
|
if (is_dir("$entryAbs/$sub") && stripos($sub, 'html') !== false) {
|
||||||
|
$htmlFolder = $sub;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dateien sammeln, aber config.yaml (und config.yml) ignorieren
|
||||||
|
$node['files'] = array_values(array_filter($children, function($child) {
|
||||||
|
if (in_array($child['type'], ['category','subcategory','ad','folder'])) return false;
|
||||||
|
if (isset($child['name']) && preg_match('/^config\.ya?ml$/i', $child['name'])) return false;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
// Spezialfall: HTML-Ordner gefunden
|
||||||
|
if ($htmlFolder) {
|
||||||
|
$htmlAbs = "$entryAbs/$htmlFolder";
|
||||||
|
$htmlIndex = "$htmlAbs/index.html";
|
||||||
|
// Suche config.yaml eine Ebene über dem HTML-Ordner (im Ad-Ordner)
|
||||||
|
$configYaml = "$entryAbs/config.yaml";
|
||||||
|
$meta = [];
|
||||||
|
if (is_file($configYaml)) {
|
||||||
|
$yaml = file_get_contents($configYaml);
|
||||||
|
$width = 0;
|
||||||
|
$height = 0;
|
||||||
|
$lines = preg_split('/\r?\n/', $yaml);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($width === 0 && preg_match('/width\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $wMatch)) {
|
||||||
|
$val = str_replace([',', ' '], '', $wMatch[1]);
|
||||||
|
$width = (int)floatval($val);
|
||||||
|
}
|
||||||
|
if ($height === 0 && preg_match('/height\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $hMatch)) {
|
||||||
|
$val = str_replace([',', ' '], '', $hMatch[1]);
|
||||||
|
$height = (int)floatval($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$meta['width'] = $width;
|
||||||
|
$meta['height'] = $height;
|
||||||
|
} else {
|
||||||
|
// Fallback: Versuche Dimensionen aus dem Ad-Ordnernamen zu extrahieren (z.B. 800x250)
|
||||||
|
$adFolderName = basename($entryAbs);
|
||||||
|
if (preg_match('/(\d{2,5})[xX](\d{2,5})/', $adFolderName, $matches)) {
|
||||||
|
$meta['width'] = (int)$matches[1];
|
||||||
|
$meta['height'] = (int)$matches[2];
|
||||||
|
// file_put_contents(__DIR__.'/debug.log', "Fallback: Dimensionen aus Ordnername erkannt width={$meta['width']} height={$meta['height']} ($adFolderName)\n", FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_file($htmlIndex)) {
|
||||||
|
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/" . ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '') . rawurlencode($entry) . "/" . rawurlencode($htmlFolder) . "/index.html";
|
||||||
|
$fileMeta = [
|
||||||
|
'name' => 'index.html',
|
||||||
|
'type' => 'html',
|
||||||
|
'url' => $url,
|
||||||
|
'size' => is_file($htmlIndex) ? filesize($htmlIndex) : null,
|
||||||
|
'width' => (isset($meta['width'])) ? $meta['width'] : 123,
|
||||||
|
'height' => (isset($meta['height'])) ? $meta['height'] : 456
|
||||||
|
];
|
||||||
|
// Füge index.html nur hinzu, wenn sie nicht schon in files ist
|
||||||
|
$already = false;
|
||||||
|
foreach ($node['files'] as $f) {
|
||||||
|
if ($f['name'] === 'index.html') { $already = true; break; }
|
||||||
|
}
|
||||||
|
if (!$already) {
|
||||||
|
$node['files'][] = $fileMeta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$node['children'] = array_filter($children, fn($c) => in_array($c['type'], ['category','subcategory','ad','folder']));
|
||||||
|
}
|
||||||
|
$result[] = $node;
|
||||||
|
} else {
|
||||||
|
// Datei
|
||||||
|
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
||||||
|
$type = match(true) {
|
||||||
|
in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']) => 'image',
|
||||||
|
in_array($ext, ['mp4', 'mov', 'webm']) => 'video',
|
||||||
|
$ext === 'html' => 'html',
|
||||||
|
default => 'file'
|
||||||
|
};
|
||||||
|
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/" . ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '') . rawurlencode($entry);
|
||||||
|
|
||||||
|
$meta = [];
|
||||||
|
$fileAbs = $entryAbs;
|
||||||
|
// Dateigröße
|
||||||
|
$meta['size'] = is_file($fileAbs) ? filesize($fileAbs) : null;
|
||||||
|
|
||||||
|
// Dimensionen für Bilder
|
||||||
|
if ($type === 'image') {
|
||||||
|
$imgInfo = @getimagesize($fileAbs);
|
||||||
|
if ($imgInfo) {
|
||||||
|
$meta['width'] = $imgInfo[0];
|
||||||
|
$meta['height'] = $imgInfo[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dimensionen und Dauer für Videos (nur wenn ffprobe verfügbar)
|
||||||
|
if ($type === 'video' && is_file($fileAbs)) {
|
||||||
|
$ffprobePath = shell_exec('which ffprobe');
|
||||||
|
$ffprobeUsed = false;
|
||||||
|
if ($ffprobePath) {
|
||||||
|
$ffprobe = trim($ffprobePath);
|
||||||
|
if ($ffprobe !== '') {
|
||||||
|
$cmd = escapeshellcmd($ffprobe) . ' -v error -select_streams v:0 -show_entries stream=width,height,duration -of default=noprint_wrappers=1:nokey=1 ' . escapeshellarg($fileAbs);
|
||||||
|
$out = shell_exec($cmd);
|
||||||
|
if ($out) {
|
||||||
|
$lines = explode("\n", trim($out));
|
||||||
|
if (count($lines) >= 3) {
|
||||||
|
$meta['width'] = (int)$lines[0];
|
||||||
|
$meta['height'] = (int)$lines[1];
|
||||||
|
$meta['duration'] = (float)$lines[2];
|
||||||
|
$ffprobeUsed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback auf getID3, falls ffprobe nicht verfügbar oder fehlgeschlagen
|
||||||
|
if (!$ffprobeUsed) {
|
||||||
|
try {
|
||||||
|
$getID3 = new \getID3();
|
||||||
|
$info = $getID3->analyze($fileAbs);
|
||||||
|
if (!empty($info['video']['resolution_x']) && !empty($info['video']['resolution_y'])) {
|
||||||
|
$meta['width'] = (int)$info['video']['resolution_x'];
|
||||||
|
$meta['height'] = (int)$info['video']['resolution_y'];
|
||||||
|
}
|
||||||
|
if (!empty($info['playtime_seconds'])) {
|
||||||
|
$meta['duration'] = (float)$info['playtime_seconds'];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fehler ignorieren, Metadaten bleiben leer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = array_merge([
|
||||||
|
'name' => $entry,
|
||||||
|
'title' => beautifyTabName($entry),
|
||||||
|
'type' => $type,
|
||||||
|
'url' => $url
|
||||||
|
], $meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($result, function($a, $b) {
|
||||||
|
if (($a['type'] === 'file') !== ($b['type'] === 'file')) {
|
||||||
|
return $a['type'] === 'file' ? 1 : -1;
|
||||||
|
}
|
||||||
|
return strnatcasecmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tree = scanAdsFolder($adsRoot, '', $baseUrl, $clientDir, $projectName);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekt→Client Mapping für Admin Smart URL Resolution
|
||||||
|
public static function getProjectClientMapping() {
|
||||||
|
// Nur Admins dürfen diese API verwenden
|
||||||
|
$user = self::requireAuth();
|
||||||
|
if (!isset($user['role']) || $user['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nur Administratoren können das Projekt-Client-Mapping abrufen.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade Admin-Daten für Client-Berechtigungen
|
||||||
|
$adminData = self::getAdminData($user['username']);
|
||||||
|
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
||||||
|
|
||||||
|
$areaPath = __DIR__ . self::getAreaPath();
|
||||||
|
$mapping = [];
|
||||||
|
|
||||||
|
// Durchsuche alle Client-Ordner
|
||||||
|
if (is_dir($areaPath)) {
|
||||||
|
$clientDirs = scandir($areaPath);
|
||||||
|
foreach ($clientDirs as $clientDir) {
|
||||||
|
if ($clientDir === '.' || $clientDir === '..' || !is_dir($areaPath . '/' . $clientDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Admin Zugriff auf diesen Client hat
|
||||||
|
if (in_array($clientDir, $disallowedClients)) {
|
||||||
|
continue; // Client ist für diesen Admin nicht erlaubt
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientPath = $areaPath . '/' . $clientDir;
|
||||||
|
if (is_dir($clientPath)) {
|
||||||
|
$projects = scandir($clientPath);
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
if ($project === '.' || $project === '..' || !is_dir($clientPath . '/' . $project)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignoriere spezielle Dateien
|
||||||
|
if (in_array($project, ['project_order.json', 'logins.json'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füge Projekt→Client Mapping hinzu
|
||||||
|
$mapping[$project] = $clientDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'mapping' => $mapping,
|
||||||
|
'adminInfo' => [
|
||||||
|
'username' => $user['username'],
|
||||||
|
'disallowedClients' => $disallowedClients
|
||||||
|
]
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Admin-Daten laden
|
||||||
|
private static function getAdminData($username) {
|
||||||
|
$adminFile = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
if (file_exists($adminFile)) {
|
||||||
|
$admins = json_decode(file_get_contents($adminFile), true);
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($admin['username'] === $username) {
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
184
backend/src/Api/UserController.php
Normal file
184
backend/src/Api/UserController.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
|
||||||
|
class UserController {
|
||||||
|
|
||||||
|
public static function currentUser() {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
if ($user) {
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'user' => $user
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_TOKEN',
|
||||||
|
'message' => 'Token ungültig oder abgelaufen.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gibt alle Benutzer als Array zurück (z.B. aus storage/data/admins.json)
|
||||||
|
public static function listAll() {
|
||||||
|
$file = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'users' => []
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$json = file_get_contents($file);
|
||||||
|
$users = json_decode($json, true);
|
||||||
|
if (!is_array($users)) $users = [];
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'users' => $users
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legt einen neuen Benutzer an
|
||||||
|
public static function create() {
|
||||||
|
$file = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input) || empty($input['username']) || empty($input['role'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_INPUT',
|
||||||
|
'message' => 'Ungültige Eingabedaten.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bestehende Benutzer laden
|
||||||
|
$users = [];
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$json = file_get_contents($file);
|
||||||
|
$users = json_decode($json, true);
|
||||||
|
if (!is_array($users)) $users = [];
|
||||||
|
}
|
||||||
|
// Neue ID generieren
|
||||||
|
$newId = 1;
|
||||||
|
if (!empty($users)) {
|
||||||
|
$ids = array_column($users, 'id');
|
||||||
|
$newId = max($ids) + 1;
|
||||||
|
}
|
||||||
|
$newUser = [
|
||||||
|
'id' => $newId,
|
||||||
|
'username' => $input['username'],
|
||||||
|
'role' => $input['role'],
|
||||||
|
'email' => $input['email'] ?? '',
|
||||||
|
// Optional: Passwort-Hash, weitere Felder
|
||||||
|
];
|
||||||
|
$users[] = $newUser;
|
||||||
|
// Speichern
|
||||||
|
file_put_contents($file, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'user' => $newUser
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearbeitet einen bestehenden Benutzer
|
||||||
|
public static function update($id) {
|
||||||
|
$file = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input) || empty($input['username']) || empty($input['role'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_INPUT',
|
||||||
|
'message' => 'Ungültige Eingabedaten.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bestehende Benutzer laden
|
||||||
|
$users = [];
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$json = file_get_contents($file);
|
||||||
|
$users = json_decode($json, true);
|
||||||
|
if (!is_array($users)) $users = [];
|
||||||
|
}
|
||||||
|
$found = false;
|
||||||
|
foreach ($users as &$user) {
|
||||||
|
if ($user['id'] == $id) {
|
||||||
|
$user['username'] = $input['username'];
|
||||||
|
$user['role'] = $input['role'];
|
||||||
|
$user['email'] = $input['email'] ?? '';
|
||||||
|
// Optional: weitere Felder wie Passwort etc.
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Benutzer nicht gefunden.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Speichern
|
||||||
|
file_put_contents($file, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'user' => $user
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Löscht einen bestehenden Benutzer
|
||||||
|
public static function delete($id) {
|
||||||
|
$file = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
$users = [];
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$json = file_get_contents($file);
|
||||||
|
$users = json_decode($json, true);
|
||||||
|
if (!is_array($users)) $users = [];
|
||||||
|
}
|
||||||
|
$found = false;
|
||||||
|
$newUsers = [];
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($user['id'] == $id) {
|
||||||
|
$found = true;
|
||||||
|
continue; // Benutzer überspringen (löschen)
|
||||||
|
}
|
||||||
|
$newUsers[] = $user;
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Benutzer nicht gefunden.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Speichern
|
||||||
|
file_put_contents($file, json_encode($newUsers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/Core/Application.php
Normal file
8
backend/src/Core/Application.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/Router.php';
|
||||||
|
class Application {
|
||||||
|
public function run() {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
Router::handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
258
backend/src/Core/Router.php
Normal file
258
backend/src/Core/Router.php
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
|
||||||
|
<?php
|
||||||
|
// Explizit Header setzen vor jeder Ausgabe
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
|
||||||
|
// Preflight OPTIONS Requests explizit beantworten und beenden
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Router {
|
||||||
|
|
||||||
|
public static function handle() {
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($uri === '/api/auth/login' && $method === 'POST') {
|
||||||
|
require_once __DIR__ . '/../Api/AuthController.php';
|
||||||
|
AuthController::login();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($uri === '/api/auth/user' && $method === 'GET') {
|
||||||
|
require_once __DIR__ . '/../Api/UserController.php';
|
||||||
|
UserController::currentUser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Admin: Benutzerliste
|
||||||
|
if ($uri === '/api/admin/users' && $method === 'GET') {
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
|
||||||
|
if ($user && $user['role'] === 'admin') {
|
||||||
|
require_once __DIR__ . '/../Api/UserController.php';
|
||||||
|
UserController::listAll();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: Benutzer anlegen
|
||||||
|
if ($uri === '/api/admin/users' && $method === 'POST') {
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
|
||||||
|
if ($user && $user['role'] === 'admin') {
|
||||||
|
require_once __DIR__ . '/../Api/UserController.php';
|
||||||
|
UserController::create();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parametrisierte Admin CRUD für Users & Clients
|
||||||
|
if (preg_match('#^/api/admin/(users|clients)(?:/(\d+))?$#', $uri, $m)) {
|
||||||
|
require_once __DIR__ . '/../Api/EntityController.php';
|
||||||
|
$type = $m[1];
|
||||||
|
$id = isset($m[2]) ? (int)$m[2] : null;
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
EntityController::list($type);
|
||||||
|
break;
|
||||||
|
case 'POST':
|
||||||
|
EntityController::create($type);
|
||||||
|
break;
|
||||||
|
case 'PUT':
|
||||||
|
EntityController::update($type, $id);
|
||||||
|
break;
|
||||||
|
case 'DELETE':
|
||||||
|
EntityController::delete($type, $id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwort-Update Route
|
||||||
|
if (preg_match('#^/api/admin/(users|clients)/(\d+)/password$#', $uri, $m) && $method === 'PUT') {
|
||||||
|
require_once __DIR__ . '/../Api/EntityController.php';
|
||||||
|
$type = $m[1];
|
||||||
|
$id = (int)$m[2];
|
||||||
|
EntityController::updatePassword($type, $id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekt→Client Mapping für Admin Smart URL Resolution
|
||||||
|
if ($uri === '/api/admin/project-client-mapping' && $method === 'GET') {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
ProjectsController::getProjectClientMapping();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uri === '/api/projects' && $method === 'GET') {
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
|
||||||
|
file_put_contents(__DIR__ . '/user_debug.log', date('c') . ' AUTH: ' . var_export($auth, true) . ' TOKEN: ' . var_export($token, true) . PHP_EOL, FILE_APPEND);
|
||||||
|
|
||||||
|
if ($user && $user['role'] === 'client' && !empty($user['dir'])) {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
ProjectsController::listForClient($user['dir']);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt oder kein Client.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: Benutzer löschen
|
||||||
|
if (preg_match('#^/api/admin/users/(\d+)$#', $uri, $m) && $method === 'DELETE') {
|
||||||
|
require_once __DIR__ . '/../Services/AuthService.php';
|
||||||
|
$headers = getallheaders();
|
||||||
|
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
$token = str_replace('Bearer ', '', $auth);
|
||||||
|
$user = AuthService::verifyJWT($token);
|
||||||
|
|
||||||
|
if ($user && $user['role'] === 'admin') {
|
||||||
|
require_once __DIR__ . '/../Api/UserController.php';
|
||||||
|
UserController::delete((int)$m[1]);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-Route für area-Zugriff
|
||||||
|
if (preg_match('#^/api/test/([^/]+)$#', $uri, $m)) {
|
||||||
|
$value = urldecode($m[1]);
|
||||||
|
$base = __DIR__ . "/../../area/$value";
|
||||||
|
$real = realpath($base);
|
||||||
|
$result = [
|
||||||
|
'requested' => $base,
|
||||||
|
'realpath' => $real,
|
||||||
|
'is_dir' => $real && is_dir($real),
|
||||||
|
'is_readable' => $real && is_readable($real),
|
||||||
|
'files' => $real && is_dir($real) ? scandir($real) : null
|
||||||
|
];
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekt-Reihenfolge speichern
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/project-order$#', $uri, $m) && $method === 'POST') {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
ProjectsController::saveProjectOrder($clientDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelnes Projekt-Objekt (inkl. Logo, Poster etc.)
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/([^/]+)$#', $uri, $m) && $method === 'GET') {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
$projectName = urldecode($m[2]);
|
||||||
|
ProjectsController::projectDetails($clientDir, $projectName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekursive Ads-Übersicht als Baumstruktur (muss VOR der generischen /ads-Route stehen!)
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/([^/]+)/ads-overview$#', $uri, $m)) {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
$projectName = urldecode($m[2]);
|
||||||
|
ProjectsController::adsOverview($clientDir, $projectName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Endpunkt für config.yaml in beliebiger Tiefe: /ads/.../config.yaml
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/([^/]+)/ads/(.+)/config\.yaml$#', $uri, $m)) {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
$projectName = urldecode($m[2]);
|
||||||
|
$folders = array_map('urldecode', explode('/', $m[3]));
|
||||||
|
ProjectsController::getConfig($clientDir, $projectName, ...$folders);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpunkt für Dateien in beliebiger Tiefe: /ads/.../files
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/([^/]+)/ads/(.+)/files$#', $uri, $m)) {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
$projectName = urldecode($m[2]);
|
||||||
|
$folders = array_map('urldecode', explode('/', $m[3]));
|
||||||
|
ProjectsController::adsFolderFiles($clientDir, $projectName, ...$folders);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Endpunkt für Unterordner in beliebiger Tiefe: /ads/... (aber nicht /files)
|
||||||
|
if (preg_match('#^/api/projects/([^/]+)/([^/]+)/ads(?:/(.+))?$#', $uri, $m) && (empty($m[3]) || !preg_match('#/files$#', $uri))) {
|
||||||
|
require_once __DIR__ . '/../Api/ProjectsController.php';
|
||||||
|
$clientDir = urldecode($m[1]);
|
||||||
|
$projectName = urldecode($m[2]);
|
||||||
|
if (empty($m[3])) {
|
||||||
|
// Nur Tab-Ordner für /ads
|
||||||
|
ProjectsController::adsFolders($clientDir, $projectName);
|
||||||
|
} else {
|
||||||
|
// Sowohl Ordner als auch Dateien für tiefere Pfade
|
||||||
|
$folders = array_map('urldecode', explode('/', $m[3]));
|
||||||
|
ProjectsController::adsFolderContent($clientDir, $projectName, ...$folders);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Route nicht gefunden.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/Helper/Helper.php
Normal file
12
backend/src/Helper/Helper.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
function loadEnv($file) {
|
||||||
|
if (!file_exists($file)) return;
|
||||||
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos(trim($line), '#') === 0) continue;
|
||||||
|
if (strpos($line, '=') === false) continue;
|
||||||
|
list($name, $value) = array_map('trim', explode('=', $line, 2));
|
||||||
|
putenv("$name=$value");
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/Services/AuthService.php
Normal file
70
backend/src/Services/AuthService.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
class AuthService {
|
||||||
|
private static function base64url_encode($data) {
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
public static function verifyJWT($jwt) {
|
||||||
|
if (!$jwt) return null;
|
||||||
|
$parts = explode('.', $jwt);
|
||||||
|
if (count($parts) !== 3) return null;
|
||||||
|
list($header, $payload, $signature) = $parts;
|
||||||
|
$valid = self::base64url_encode(hash_hmac('sha256', "$header.$payload", self::$jwtSecret, true));
|
||||||
|
if (!hash_equals($valid, $signature)) return null;
|
||||||
|
$data = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
|
||||||
|
if (!$data || ($data['exp'] ?? 0) < time()) return null;
|
||||||
|
// User-Infos je nach Rolle ergänzen
|
||||||
|
if ($data['role'] === 'admin') {
|
||||||
|
return [
|
||||||
|
'username' => $data['id'],
|
||||||
|
'role' => 'admin'
|
||||||
|
];
|
||||||
|
} elseif ($data['role'] === 'client') {
|
||||||
|
return [
|
||||||
|
'client' => $data['dir'],
|
||||||
|
'role' => 'client',
|
||||||
|
'dir' => $data['dir']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static $adminFile = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
private static $clientFile = __DIR__ . '/../../storage/data/clients.json';
|
||||||
|
private static $jwtSecret = 'adspreview_secret_2025';
|
||||||
|
|
||||||
|
public static function authenticate($username, $password) {
|
||||||
|
if ($username) {
|
||||||
|
// Admin-Login
|
||||||
|
$admins = json_decode(file_get_contents(self::$adminFile), true);
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($admin['username'] === $username && password_verify($password, $admin['password'])) {
|
||||||
|
$token = self::generateJWT($admin['username'], '', 'admin');
|
||||||
|
return ['success' => true, 'token' => $token, 'role' => 'admin'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => 'Falscher Benutzername oder Passwort.'];
|
||||||
|
} else {
|
||||||
|
// Client-Login (nur Passwort)
|
||||||
|
$clients = json_decode(file_get_contents(self::$clientFile), true);
|
||||||
|
foreach ($clients as $key => $client) {
|
||||||
|
if ($client['pass'] === md5($password)) {
|
||||||
|
$token = self::generateJWT($key, $client['dir'], 'client');
|
||||||
|
return ['success' => true, 'token' => $token, 'role' => 'client'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => 'Falsches Passwort.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function generateJWT($id, $dir, $role) {
|
||||||
|
$header = self::base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||||
|
$payload = self::base64url_encode(json_encode([
|
||||||
|
'id' => $id,
|
||||||
|
'dir' => $dir,
|
||||||
|
'role' => $role,
|
||||||
|
'exp' => time() + 60 * 60 * 12 // 12 Stunden
|
||||||
|
]));
|
||||||
|
$signature = hash_hmac('sha256', "$header.$payload", self::$jwtSecret, true);
|
||||||
|
$signature = self::base64url_encode($signature);
|
||||||
|
return "$header.$payload.$signature";
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/Services/JsonStorageService.php
Normal file
23
backend/src/Services/JsonStorageService.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class JsonStorageService {
|
||||||
|
private static function getFilePath(string $type): string {
|
||||||
|
return __DIR__ . '/../../storage/data/' . $type . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function read(string $type): array {
|
||||||
|
$file = self::getFilePath($type);
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$json = file_get_contents($file);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function write(string $type, array $data): bool {
|
||||||
|
$file = self::getFilePath($type);
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
return file_put_contents($file, $json) !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/storage/.htaccess
Normal file
11
backend/storage/.htaccess
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Zugriff auf Storage-Dateien verbieten
|
||||||
|
<Files "*.json">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# Zugriff auf Log-Dateien verbieten
|
||||||
|
<Files "*.log">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</Files>
|
||||||
20
backend/storage/data/admins.json
Normal file
20
backend/storage/data/admins.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "$2a$12$s8EiG.SOcQm\/avdEwsX0ve9QXqW7KbKpKwQOLEZPTY1mIzY5nCUVm",
|
||||||
|
"role": "admin",
|
||||||
|
"email": "",
|
||||||
|
"disallowedClients": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "Johannes",
|
||||||
|
"role": "admin",
|
||||||
|
"email": "j.kluge@duwfamily.de",
|
||||||
|
"disallowedClients": [
|
||||||
|
"Paramount"
|
||||||
|
],
|
||||||
|
"password": "$2y$10$T059TgvbwRwmSOiqqhlrLeMeaxWAYZd4mT9ot\/tCTG.ldUi4pvk.K"
|
||||||
|
}
|
||||||
|
]
|
||||||
318
backend/storage/data/clients.json
Normal file
318
backend/storage/data/clients.json
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
{
|
||||||
|
"AllScreens": {
|
||||||
|
"pass": "282362f6d534e4106b25a9400a355ace",
|
||||||
|
"dir": "AllScreens",
|
||||||
|
"in": "Hallo! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"Arthaus": {
|
||||||
|
"pass": "f978da4380dda2b14eb0e14fed27479d",
|
||||||
|
"dir": "Arthaus",
|
||||||
|
"in": "Hallo Arthaus! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Cromatics": {
|
||||||
|
"pass": "90bebd2f0dc248887ba9d779a95c78f3",
|
||||||
|
"dir": "Cromatics",
|
||||||
|
"in": "Hallo Cromatics! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"DCM": {
|
||||||
|
"pass": "64651a9d82a5ac27476ff2d1d26022b4",
|
||||||
|
"dir": "DCM",
|
||||||
|
"in": "Hi DCM, toll dass ihr hier vorbeischaut!",
|
||||||
|
"out": "Kommt bald mal wieder!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Delasocial": {
|
||||||
|
"pass": "2faa7c30137f77c7ac8ddeee3a0afcc7",
|
||||||
|
"dir": "Delasocial",
|
||||||
|
"in": "Hallo Delasocial! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Disney": {
|
||||||
|
"pass": "9f234b3b8a14d247e8c8b7c566b7c5c1",
|
||||||
|
"dir": "Disney",
|
||||||
|
"in": "Hey Disney! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"DUW": {
|
||||||
|
"pass": "c3875d07f44c422f3b3bc019c23e16ae",
|
||||||
|
"dir": "DruckUndWerte",
|
||||||
|
"in": "Aloha und willkommen allerseits!",
|
||||||
|
"out": "Aloha und bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"DUW_ex": {
|
||||||
|
"pass": "9f917c4ee8fbfd6ea6337a9c66a5f28b",
|
||||||
|
"dir": "DruckUndWerte_extern",
|
||||||
|
"in": "Hallo, schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"DOHR": {
|
||||||
|
"pass": "7c2090fa35a78ad8b97903bc957e10d3",
|
||||||
|
"dir": "DOHR",
|
||||||
|
"in": "Hallo, schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"FARBFILM": {
|
||||||
|
"pass": "431dc785c3b06782a16792f53601b560",
|
||||||
|
"dir": "Farbfilm",
|
||||||
|
"in": "Hallo Farbfilm! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"FODI": {
|
||||||
|
"pass": "aba74476945b8c5695da91ef7d228591",
|
||||||
|
"dir": "Forever Digital",
|
||||||
|
"in": "Hallo Forever Digital! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"FREELANCER": {
|
||||||
|
"pass": "3ff47a2d7c0f78a49c8774990d82ccdd",
|
||||||
|
"dir": "Freelancer",
|
||||||
|
"in": "Hallo Freelancer! Schön, dass du da bist.",
|
||||||
|
"out": "Danke für deinen Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"HERZ": {
|
||||||
|
"pass": "97456273d78bfb02a13f7ae3fc39d968",
|
||||||
|
"dir": "Herzkampf",
|
||||||
|
"in": "Hallo Martin! Sehr cool, dass du vorbeischaust.",
|
||||||
|
"out": "Danke für deinen Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"HONKYTONK": {
|
||||||
|
"pass": "3f739972a5ed0418b23a3b657022529a",
|
||||||
|
"dir": "Honky_Tonk",
|
||||||
|
"in": "Hallo Honky Tonk! Sehr cool, dass du vorbeischaust.",
|
||||||
|
"out": "Danke für deinen Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"IAK": {
|
||||||
|
"pass": "b683e7e38b1b31d6ba7d52e5707eacf7",
|
||||||
|
"dir": "IAK_Projekte",
|
||||||
|
"in": "Hallo IAK! Sehr cool, dass du vorbeischaust.",
|
||||||
|
"out": "Danke für deinen Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"KOCH": {
|
||||||
|
"pass": "c18c81318d17d0a77f6264877360cfa6",
|
||||||
|
"dir": "Koch",
|
||||||
|
"in": "Hallo Koch Media. Sehr cool, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"KREATIVORTE_SACHSEN": {
|
||||||
|
"pass": "40be028f0c67ad526f95915404d921dd",
|
||||||
|
"dir": "Kreativorte_Sachsen",
|
||||||
|
"in": "Hallo Kreatives Sachsen. Sehr cool, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"LEONINE": {
|
||||||
|
"pass": "79cfab624c6f02057146c1cdadff2edf",
|
||||||
|
"dir": "Leonine",
|
||||||
|
"in": "Hallo Leonine Studios. Sehr cool, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"MAWI": {
|
||||||
|
"pass": "3f2c3763d1eaf85c32a95f12f129e482",
|
||||||
|
"dir": "Mawi",
|
||||||
|
"in": "Hallo MAWI",
|
||||||
|
"out": "Bis bald mal wieder!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"BIO": {
|
||||||
|
"pass": "292c525c1552ff3a76e8c774fcdc3c6d",
|
||||||
|
"dir": "Neue_Bioskop",
|
||||||
|
"in": "Hallo Neue_Bioskop",
|
||||||
|
"out": "Bis bald mal wieder!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"GEWAND": {
|
||||||
|
"pass": "9976c5c3ee90a05e19bdf99f094c4cb6",
|
||||||
|
"dir": "Gewandhaus",
|
||||||
|
"in": "Hallo Gewandhaus Orchester",
|
||||||
|
"out": "Bis bald mal wieder!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"OEMG": {
|
||||||
|
"pass": "c921775f278ccf9b30259e2ccc9c8316",
|
||||||
|
"dir": "Oekomarktgemeinschaft",
|
||||||
|
"in": "Hey ÖMG! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PARA": {
|
||||||
|
"pass": "fc8b22f41628b89ce77d2e705d17344c",
|
||||||
|
"dir": "Paramount",
|
||||||
|
"in": "Hey Paramount! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PARA_EXT": {
|
||||||
|
"pass": "fb4103032c7d126cba199df4283104af",
|
||||||
|
"dir": "Paramount_extern",
|
||||||
|
"in": "Hallo! Schön, dass Sie da sind.",
|
||||||
|
"out": "Danke für Ihren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PLAION": {
|
||||||
|
"pass": "ba186491e569565b13b797779792a830",
|
||||||
|
"dir": "Plaion",
|
||||||
|
"in": "Hallo Plaion! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PORT": {
|
||||||
|
"pass": "118c9af69a42383387e8ce6ab22867d7",
|
||||||
|
"dir": "PortAuPrince",
|
||||||
|
"in": "Hallo Port Au Prince! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PREVIEW": {
|
||||||
|
"pass": "5ebeb6065f64f2346dbb00ab789cf001",
|
||||||
|
"dir": "Preview",
|
||||||
|
"in": "Hey Watcher! Schön, dass Du da bist.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"SHOWAD": {
|
||||||
|
"pass": "fc5bebaa11dfefe697de7270f3f50962",
|
||||||
|
"dir": "ShowAd",
|
||||||
|
"in": "Willkommen ShowAd",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"SMH": {
|
||||||
|
"pass": "fe34aa295723a5886cc6b880772d22e9",
|
||||||
|
"dir": "Stadtmarketing_Halle",
|
||||||
|
"in": "Willkommen Stadtmarketing Halle",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"SQUAREONE": {
|
||||||
|
"pass": "12f85fdf12a50de1f8f209c9080ea937",
|
||||||
|
"dir": "Square_One_Entertainment",
|
||||||
|
"in": "Hey SquareOne Entertainment! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"STADT": {
|
||||||
|
"pass": "80be0458f1cb1f7ee8c2b979c2b5f52a",
|
||||||
|
"dir": "Stadtgespraech",
|
||||||
|
"in": "Hallo Stadtgespräch! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"STC": {
|
||||||
|
"pass": "2483e14219cce6fe63d8ac91afc92618",
|
||||||
|
"dir": "STC",
|
||||||
|
"in": "Hallo Studiocanal! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"STC_EXT": {
|
||||||
|
"pass": "cca944a0fe3c28c377d9056846b9348e",
|
||||||
|
"dir": "STC_extern",
|
||||||
|
"in": "Hallo! Schön, dass Du da bist.",
|
||||||
|
"out": "Danke für Deinen Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Studiocanal": {
|
||||||
|
"pass": "cea12e6c71d1264a896d8db81d2c0fdc",
|
||||||
|
"dir": "Studiocanal",
|
||||||
|
"in": "Hallo Studiocanal! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"THEO_Werbeagentur": {
|
||||||
|
"pass": "267e8c911ca96f658543693e2e5f19ac",
|
||||||
|
"dir": "THEO_Werbeagentur",
|
||||||
|
"in": "Hallo THEO! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Tobis": {
|
||||||
|
"pass": "b7cff13b0234c8b67a0ad80f3b58381c",
|
||||||
|
"dir": "Tobis",
|
||||||
|
"in": "Hallo TOBIS! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"TPDOMO": {
|
||||||
|
"pass": "83f0daabd67bff33021f8ce2b19244b3",
|
||||||
|
"dir": "TorpedoMoto",
|
||||||
|
"in": "Nie zufrieden.",
|
||||||
|
"out": "We are friends of creative and digital marketing ;)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Universal_Music": {
|
||||||
|
"pass": "68a4f8d71c5a11701957f1276a74b217",
|
||||||
|
"dir": "Universal_Music",
|
||||||
|
"in": "Hallo Universal Music! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WARN": {
|
||||||
|
"pass": "7cb825305140bd57e6475ac54711c4f0",
|
||||||
|
"dir": "Warner",
|
||||||
|
"in": "Hallo Warner Bros.! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WARNERMUSIC": {
|
||||||
|
"pass": "aca10ece993488f9b58b7f392c45b205",
|
||||||
|
"dir": "WarnerMusic",
|
||||||
|
"in": "Hallo Warner Bros.! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WEIN": {
|
||||||
|
"pass": "2bf36d400f27b62790464572f2e6d16b",
|
||||||
|
"dir": "WeinRieder",
|
||||||
|
"in": "Hey Wein.Rieder! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WTK": {
|
||||||
|
"pass": "eb35c17c47b8ea645be204aba44cae3d",
|
||||||
|
"dir": "Weltkino",
|
||||||
|
"in": "Hey Weltkino! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"PLA": {
|
||||||
|
"pass": "ba186491e569565b13b797779792a830",
|
||||||
|
"dir": "Plaion",
|
||||||
|
"in": "Hey Plaion! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WB": {
|
||||||
|
"pass": "a10a7b46a6e2be8c070e3305a300492e",
|
||||||
|
"dir": "Wildbunch",
|
||||||
|
"in": "Hey Wildbunch! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
},
|
||||||
|
|
||||||
|
"WOODWALKERS": {
|
||||||
|
"pass": "9163f2b67e738edf854cbfda80aef9af",
|
||||||
|
"dir": "Woodwalkers",
|
||||||
|
"in": "Hallo Studiocanal! Schön, dass ihr da seid.",
|
||||||
|
"out": "Danke für Euren Besuch, bis später!"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
backend/storage/data/viewers.json
Normal file
7
backend/storage/data/viewers.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Viewer_Sample": {
|
||||||
|
"pass": "fc8b22f41628b89ce8102e705d1734kf",
|
||||||
|
"dir": "Paramount",
|
||||||
|
"tab": "Beispiele"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
deployment/nginx/README.md
Normal file
45
deployment/nginx/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# nginx Configuration for AdsPreview React
|
||||||
|
|
||||||
|
Diese Datei enthält eine Beispiel-nginx-Konfiguration für die AdsPreview React Anwendung.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Konfiguration kopieren:**
|
||||||
|
```bash
|
||||||
|
sudo cp adspreview-react.example.conf /etc/nginx/sites-available/adspreview-react
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Konfiguration anpassen:**
|
||||||
|
- `server_name` auf Ihre Domain ändern
|
||||||
|
- Pfade entsprechend Ihrer Installation anpassen
|
||||||
|
- SSL-Zertifikate konfigurieren (falls HTTPS)
|
||||||
|
|
||||||
|
3. **Site aktivieren:**
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/adspreview-react /etc/nginx/sites-enabled/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **nginx testen und neustarten:**
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **SPA Routing:** React Router wird korrekt unterstützt
|
||||||
|
- **API Proxy:** `/api/` Requests werden an PHP Backend weitergeleitet
|
||||||
|
|
||||||
|
## Pfad-Anpassungen
|
||||||
|
|
||||||
|
Stellen Sie sicher, dass folgende Pfade korrekt sind:
|
||||||
|
|
||||||
|
- `root /var/www/adspreview-react/build;` - Pfad zum Built React App
|
||||||
|
- `proxy_pass http://example.com;` - PHP Backend URL
|
||||||
|
- SSL Zertifikat-Pfade (falls HTTPS verwendet wird)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **404 bei React Routes:** Überprüfen Sie `try_files $uri $uri/ /index.html;`
|
||||||
|
- **API Calls funktionieren nicht:** Prüfen Sie Backend-URL und -Port
|
||||||
|
- **Static Assets laden nicht:** Überprüfen Sie `root` Pfad
|
||||||
121
deployment/nginx/adspreview-react.example.conf
Normal file
121
deployment/nginx/adspreview-react.example.conf
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# nginx configuration for AdsPreview React Application
|
||||||
|
# Copy this file to your nginx sites-available directory and adjust paths as needed
|
||||||
|
# Example: sudo cp adspreview-react.example.conf /etc/nginx/sites-available/adspreview-react
|
||||||
|
# Then: sudo ln -s /etc/nginx/sites-available/adspreview-react /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
{{ssl_certificate_key}}
|
||||||
|
{{ssl_certificate}}
|
||||||
|
server_name adspreview.example.com;
|
||||||
|
root /home/adspreview/htdocs/adspreview.example.com/public;
|
||||||
|
|
||||||
|
{{nginx_access_log}}
|
||||||
|
{{nginx_error_log}}
|
||||||
|
|
||||||
|
if ($scheme != "https") {
|
||||||
|
rewrite ^ https://$host$uri permanent;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /.well-known {
|
||||||
|
auth_basic off;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{settings}}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
{{varnish_proxy_pass}}
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Varnish;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
|
proxy_connect_timeout 720;
|
||||||
|
proxy_send_timeout 720;
|
||||||
|
proxy_read_timeout 720;
|
||||||
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
|
proxy_temp_file_write_size 256k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statische Assets über Proxy weiterleiten
|
||||||
|
location /static/ {
|
||||||
|
{{varnish_proxy_pass}}
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Varnish;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|map|mjs)$ {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
expires max;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(ht|svn|git) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-f $request_filename) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
server_name adspreview.example.com;
|
||||||
|
root /home/adspreview/htdocs/adspreview.example.com/public;
|
||||||
|
|
||||||
|
index index.html index.php;
|
||||||
|
|
||||||
|
# Area-Ordner direkt ausliefern (Alias!)
|
||||||
|
location /area/ {
|
||||||
|
alias /home/adspreview/htdocs/adspreview.example.com/area/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Requests immer an index.php
|
||||||
|
location ~ ^/api/ {
|
||||||
|
try_files $uri $uri/ /index.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statische Assets (CSS, JS, Bilder, etc.)
|
||||||
|
location /static/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP-Dateien verarbeiten
|
||||||
|
location ~ \.php$ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_intercept_errors on;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_read_timeout 3600;
|
||||||
|
fastcgi_send_timeout 3600;
|
||||||
|
fastcgi_param HTTPS "on";
|
||||||
|
fastcgi_param SERVER_PORT 443;
|
||||||
|
fastcgi_pass 127.0.0.1:{{php_fpm_port}};
|
||||||
|
fastcgi_param PHP_VALUE "{{php_settings}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
# React SPA Fallback - alle anderen Routen auf index.html weiterleiten
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
deployment/scripts/.env.upload.example
Normal file
20
deployment/scripts/.env.upload.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# FTP/SFTP Zugangsdaten für Webspace Upload
|
||||||
|
# Kopiere diese Datei zu .env.upload und trage deine echten Zugangsdaten ein
|
||||||
|
|
||||||
|
# Server-Adresse (ohne http://, nur Hostname)
|
||||||
|
FTP_HOST="dein-server.de"
|
||||||
|
|
||||||
|
# FTP/SFTP Benutzername
|
||||||
|
FTP_USER="dein-username"
|
||||||
|
|
||||||
|
# FTP/SFTP Passwort
|
||||||
|
FTP_PASS="dein-passwort"
|
||||||
|
|
||||||
|
# Pfad auf dem Server (meist /htdocs, /public_html, /www oder /)
|
||||||
|
FTP_PATH="/htdocs"
|
||||||
|
|
||||||
|
# Beispiele für verschiedene Provider:
|
||||||
|
# Strato: FTP_HOST="ftp.strato.de" FTP_PATH="/htdocs"
|
||||||
|
# 1&1: FTP_HOST="ftp.1und1.de" FTP_PATH="/htdocs"
|
||||||
|
# All-Inkl: FTP_HOST="ftp.all-inkl.com" FTP_PATH="/"
|
||||||
|
# Hetzner: FTP_HOST="deine-domain.de" FTP_PATH="/httpdocs"
|
||||||
107
deployment/scripts/README.md
Normal file
107
deployment/scripts/README.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Deployment Scripts
|
||||||
|
|
||||||
|
Diese Verzeichnis enthält alle Scripts für das Deployment der AdsPreview React Anwendung.
|
||||||
|
|
||||||
|
## 📁 Scripts Übersicht
|
||||||
|
|
||||||
|
### `setup.sh` - Initiales Setup
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
- Installiert Frontend Dependencies
|
||||||
|
- Erstellt Development Build zum Testen
|
||||||
|
- Bereitet Backend-Verzeichnisse vor
|
||||||
|
- Erstellt Standard-Konfigurationsdateien
|
||||||
|
|
||||||
|
### `deploy.sh` - Production Deployment
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
- Erstellt optimierten Production Build
|
||||||
|
- Kopiert alle notwendigen Dateien nach `deployment/build/`
|
||||||
|
- Setzt korrekte Dateiberechtigungen
|
||||||
|
- Bereitet Upload-ready Struktur vor
|
||||||
|
|
||||||
|
### `upload.sh` - Webspace Upload
|
||||||
|
```bash
|
||||||
|
# WICHTIG: Erst .env.upload konfigurieren!
|
||||||
|
cp .env.upload.example .env.upload
|
||||||
|
# Bearbeite .env.upload mit deinen FTP-Zugangsdaten
|
||||||
|
./upload.sh
|
||||||
|
```
|
||||||
|
- Uploaded `deployment/build/` Inhalt via SFTP/FTP
|
||||||
|
- **🔒 SICHER:** Verwendet .env.upload für Zugangsdaten (nicht in Git!)
|
||||||
|
- Benötigt `lftp` Tool (`brew install lftp`)
|
||||||
|
|
||||||
|
## 🚀 Deployment Workflow
|
||||||
|
|
||||||
|
1. **Einmalig:** Setup ausführen
|
||||||
|
```bash
|
||||||
|
cd deployment/scripts
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **FTP-Zugangsdaten konfigurieren (einmalig):**
|
||||||
|
```bash
|
||||||
|
cp .env.upload.example .env.upload
|
||||||
|
# Bearbeite .env.upload mit deinen echten Zugangsdaten
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Production Build erstellen:**
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Auf Webspace hochladen:**
|
||||||
|
```bash
|
||||||
|
./upload.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Deployment-Struktur
|
||||||
|
|
||||||
|
Nach `deploy.sh` wird folgende Struktur erstellt:
|
||||||
|
|
||||||
|
```
|
||||||
|
deployment/build/
|
||||||
|
├── public/ # React App + PHP Entry Points
|
||||||
|
│ ├── index.html # React SPA
|
||||||
|
│ ├── index.php # PHP Backend Entry
|
||||||
|
│ ├── files.php # File Service
|
||||||
|
│ └── static/ # React Assets (CSS, JS)
|
||||||
|
├── src/ # PHP Backend Code
|
||||||
|
│ ├── Api/ # API Controllers
|
||||||
|
│ ├── Core/ # Application Core
|
||||||
|
│ ├── Helper/ # Utility Functions
|
||||||
|
│ └── Services/ # Business Logic
|
||||||
|
├── storage/ # Application Data
|
||||||
|
│ └── data/ # JSON Storage Files
|
||||||
|
└── vendor/ # PHP Dependencies (GetID3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## <20> Sicherheitshinweise
|
||||||
|
|
||||||
|
### FTP-Zugangsdaten (.env.upload)
|
||||||
|
- **NIEMALS** Zugangsdaten direkt ins Script schreiben
|
||||||
|
- **IMMER** .env.upload für sensible Daten verwenden
|
||||||
|
- .env.upload ist automatisch von Git ausgeschlossen
|
||||||
|
- Template: .env.upload.example zeigt die Struktur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.upload Beispiel:
|
||||||
|
FTP_HOST="dein-server.de"
|
||||||
|
FTP_USER="dein-username"
|
||||||
|
FTP_PASS="dein-passwort"
|
||||||
|
FTP_PATH="/htdocs"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Anpassungen
|
||||||
|
|
||||||
|
### nginx Konfiguration
|
||||||
|
Siehe `../nginx/` Verzeichnis für Server-Konfiguration.
|
||||||
|
|
||||||
|
## 💡 Tipps
|
||||||
|
|
||||||
|
- **Scripts laufen von überall:** Die Scripts erkennen automatisch ihren Standort
|
||||||
|
- **Backup:** Sichere vor Upload immer deine Live-Daten
|
||||||
|
- **Testing:** Teste zuerst mit `setup.sh` bevor du `deploy.sh` verwendest
|
||||||
|
- **Logs:** Scripts geben detaillierte Status-Informationen aus
|
||||||
97
deployment/scripts/deploy.sh
Executable file
97
deployment/scripts/deploy.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Deployment-Skript für Production Update
|
||||||
|
|
||||||
|
echo "🚀 Starte Production Deployment..."
|
||||||
|
|
||||||
|
# Prüfe ob wir im deployment/scripts/ Ordner sind und wechsle zur Projekt-Root
|
||||||
|
if [ "$(basename "$PWD")" = "scripts" ]; then
|
||||||
|
cd ../..
|
||||||
|
elif [ "$(basename "$PWD")" = "deployment" ]; then
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deployment-Ordner vorbereiten
|
||||||
|
echo "📁 Erstelle Deployment-Ordner..."
|
||||||
|
rm -rf deployment/build 2>/dev/null || true
|
||||||
|
mkdir -p deployment/build/public
|
||||||
|
|
||||||
|
# Frontend bauen
|
||||||
|
echo "📦 Baue Frontend..."
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Temporären Build-Ordner erstellen
|
||||||
|
echo "📁 Erstelle temporären Build-Ordner..."
|
||||||
|
rm -rf ../build_temp 2>/dev/null || true
|
||||||
|
mkdir -p ../build_temp
|
||||||
|
|
||||||
|
# Abhängigkeiten installieren (ohne die lokalen node_modules zu überschreiben)
|
||||||
|
echo "⬇️ Installiere Production Dependencies..."
|
||||||
|
cp package*.json ../build_temp/
|
||||||
|
cd ../build_temp
|
||||||
|
npm ci --production --silent
|
||||||
|
|
||||||
|
# Source-Code kopieren (ohne node_modules)
|
||||||
|
echo "📄 Kopiere Source-Code..."
|
||||||
|
cd ../frontend
|
||||||
|
cp -r public src ../build_temp/
|
||||||
|
cd ../build_temp
|
||||||
|
|
||||||
|
# Build erstellen
|
||||||
|
echo "🔨 Erstelle Production Build..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Frontend-Build in public-Ordner des Deployments kopieren
|
||||||
|
echo "📦 Kopiere Frontend-Build ins public-Verzeichnis..."
|
||||||
|
cp -r build/* ../deployment/build/public/
|
||||||
|
|
||||||
|
# Backend-Verzeichnisse in der Root des Deployments erstellen
|
||||||
|
echo "📄 Kopiere Backend-Dateien mit korrekter Struktur..."
|
||||||
|
cd ../backend
|
||||||
|
|
||||||
|
# Kopiere Backend-Verzeichnisse in die Root des Deployment-Ordners (als komplette Ordner)
|
||||||
|
cp -r src ../deployment/build/
|
||||||
|
cp -r storage ../deployment/build/
|
||||||
|
cp -r vendor ../deployment/build/
|
||||||
|
|
||||||
|
# PHP-Dateien aus backend/public in das public-Verzeichnis des Deployments kopieren
|
||||||
|
cd public
|
||||||
|
for file in *.php .htaccess .nginx; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
cp "$file" ../../deployment/build/public/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# area-Ordner in die Root des Deployment-Ordners kopieren
|
||||||
|
#echo "📁 Kopiere area-Verzeichnis..."
|
||||||
|
#cd ../..
|
||||||
|
#cp -r area deployment/build/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Temporären Build-Ordner aufräumen
|
||||||
|
echo "🧹 Räume temporären Build-Ordner auf..."
|
||||||
|
cd ../..
|
||||||
|
rm -rf build_temp
|
||||||
|
|
||||||
|
# Rechte setzen
|
||||||
|
echo "🔒 Setze Dateiberechtigungen..."
|
||||||
|
chmod -R 755 deployment/build
|
||||||
|
find deployment/build -type f -name "*.html" -exec chmod 644 {} \;
|
||||||
|
find deployment/build -type f -name "*.css" -exec chmod 644 {} \;
|
||||||
|
find deployment/build -type f -name "*.js" -exec chmod 644 {} \;
|
||||||
|
find deployment/build -type f -name "*.php" -exec chmod 644 {} \;
|
||||||
|
find deployment/build -type f -name "*.json" -exec chmod 644 {} \;
|
||||||
|
|
||||||
|
echo "✅ Production Deployment abgeschlossen!"
|
||||||
|
echo "🎯 Deployment-ready Dateien sind im 'deployment/build/' Ordner"
|
||||||
|
echo ""
|
||||||
|
echo "📋 DEPLOYMENT STRUKTUR:"
|
||||||
|
echo " deployment/build/"
|
||||||
|
echo " ├── public/ (React App + PHP Entry Points)"
|
||||||
|
echo " │ ├── index.html (React)"
|
||||||
|
echo " │ ├── index.php (PHP Entry Point)"
|
||||||
|
echo " │ └── static/ (React Assets)"
|
||||||
|
echo " ├── src/ (PHP Backend Code)"
|
||||||
|
echo " ├── storage/ (Data Files)"
|
||||||
|
echo " ├── vendor/ (PHP Dependencies)"
|
||||||
|
echo " └── area/ (Project Data)"
|
||||||
|
echo ""
|
||||||
|
echo "📤 Du kannst jetzt den kompletten Inhalt des 'deployment/build/' Ordners auf deinen Webspace hochladen."
|
||||||
52
deployment/scripts/setup.sh
Executable file
52
deployment/scripts/setup.sh
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Setup-Skript für initiales Deployment
|
||||||
|
|
||||||
|
echo "🔧 Starte initiales Setup..."
|
||||||
|
|
||||||
|
# Prüfe ob wir im deployment/scripts/ Ordner sind und wechsle zur Projekt-Root
|
||||||
|
if [ "$(basename "$PWD")" = "scripts" ]; then
|
||||||
|
cd ../..
|
||||||
|
elif [ "$(basename "$PWD")" = "deployment" ]; then
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
echo "📦 Installiere Frontend Dependencies..."
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development Build für Testing
|
||||||
|
echo "🛠️ Erstelle Development Build..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Build ins Backend kopieren
|
||||||
|
echo "📁 Kopiere Build-Dateien..."
|
||||||
|
cp -r build/* ../backend/public/
|
||||||
|
|
||||||
|
# Rechte setzen
|
||||||
|
echo "🔒 Setze Dateiberechtigungen..."
|
||||||
|
chmod -R 755 ../backend/public
|
||||||
|
|
||||||
|
# Erstelle notwendige Verzeichnisse
|
||||||
|
echo "📂 Erstelle Backend-Verzeichnisse..."
|
||||||
|
mkdir -p ../backend/storage/data
|
||||||
|
chmod 755 ../backend/storage
|
||||||
|
chmod 755 ../backend/storage/data
|
||||||
|
|
||||||
|
# Erstelle Standard-Konfigurationsdateien falls nicht vorhanden
|
||||||
|
cd ../backend/storage/data
|
||||||
|
if [ ! -f "admins.json" ]; then
|
||||||
|
echo '[]' > admins.json
|
||||||
|
chmod 644 admins.json
|
||||||
|
fi
|
||||||
|
if [ ! -f "viewers.json" ]; then
|
||||||
|
echo '[]' > viewers.json
|
||||||
|
chmod 644 viewers.json
|
||||||
|
fi
|
||||||
|
if [ ! -f "clients.json" ]; then
|
||||||
|
echo '[]' > clients.json
|
||||||
|
chmod 644 clients.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Setup abgeschlossen!"
|
||||||
|
echo "📝 Konfiguriere nun deine Benutzer in backend/storage/data/"
|
||||||
90
deployment/scripts/upload.sh
Executable file
90
deployment/scripts/upload.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Upload-Skript für Webspace Deployment
|
||||||
|
|
||||||
|
echo "📤 Webspace Upload Skript"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Konfiguration aus .env.upload Datei laden
|
||||||
|
ENV_FILE="$(dirname "$0")/.env.upload"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "❌ Konfigurationsdatei nicht gefunden: $ENV_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "📋 ERSTELLE EINE .env.upload DATEI:"
|
||||||
|
echo " 1. Kopiere .env.upload.example zu .env.upload"
|
||||||
|
echo " 2. Trage deine FTP-Zugangsdaten ein"
|
||||||
|
echo ""
|
||||||
|
echo " Beispiel:"
|
||||||
|
echo " FTP_HOST=\"dein-server.de\""
|
||||||
|
echo " FTP_USER=\"dein-username\""
|
||||||
|
echo " FTP_PASS=\"dein-passwort\""
|
||||||
|
echo " FTP_PATH=\"/htdocs\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Environment-Variablen aus .env.upload laden
|
||||||
|
set -a # Export aller Variablen
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
echo "🔧 KONFIGURATION:"
|
||||||
|
echo " Host: $FTP_HOST"
|
||||||
|
echo " User: $FTP_USER"
|
||||||
|
echo " Path: $FTP_PATH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob wir im deployment/scripts/ Ordner sind und wechsle zur Projekt-Root
|
||||||
|
if [ "$(basename "$PWD")" = "scripts" ]; then
|
||||||
|
cd ../..
|
||||||
|
elif [ "$(basename "$PWD")" = "deployment" ]; then
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Überprüfe ob deployment/build Ordner existiert
|
||||||
|
if [ ! -d "deployment/build" ]; then
|
||||||
|
echo "❌ deployment/build/ Ordner nicht gefunden."
|
||||||
|
echo " Führe zuerst das deploy.sh Skript aus!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Überprüfe ob lftp installiert ist
|
||||||
|
if ! command -v lftp &> /dev/null; then
|
||||||
|
echo "❌ lftp ist nicht installiert."
|
||||||
|
echo " Installation: brew install lftp"
|
||||||
|
echo ""
|
||||||
|
echo "📋 MANUELLE UPLOAD-ANLEITUNG:"
|
||||||
|
echo " 1. Öffne deinen FTP Client (FileZilla, etc.)"
|
||||||
|
echo " 2. Verbinde zu deinem Webspace"
|
||||||
|
echo " 3. Navigiere zu htdocs/ oder public_html/"
|
||||||
|
echo " 4. Uploade alle Dateien aus: deployment/build/"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⚠️ ACHTUNG: Dies überschreibt alle Dateien auf dem Webspace!"
|
||||||
|
echo " Fortfahren? (y/N)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Abgebrochen."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📤 Uploade via SFTP..."
|
||||||
|
lftp -c "
|
||||||
|
set sftp:auto-confirm yes;
|
||||||
|
set ssl:verify-certificate no;
|
||||||
|
open sftp://$FTP_USER:$FTP_PASS@$FTP_HOST;
|
||||||
|
cd $FTP_PATH;
|
||||||
|
|
||||||
|
lcd deployment/build;
|
||||||
|
mirror --reverse --delete --verbose --exclude-glob=node_modules/ --exclude-glob=.git/ --exclude-glob=.* --exclude area/ ./ ./;
|
||||||
|
|
||||||
|
bye
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Upload erfolgreich!"
|
||||||
|
echo "🌐 Deine App sollte jetzt live sein!"
|
||||||
|
else
|
||||||
|
echo "❌ Upload fehlgeschlagen. Prüfe deine FTP-Zugangsdaten."
|
||||||
|
fi
|
||||||
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
REACT_APP_BACKEND=http://localhost:8000
|
||||||
|
AREA_PATH=/../../../area
|
||||||
10
frontend/.env.production
Normal file
10
frontend/.env.production
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Production Debug-Konfiguration
|
||||||
|
REACT_APP_DEBUG_ENABLED=false
|
||||||
|
|
||||||
|
# Alle Debug-Optionen deaktiviert für Production
|
||||||
|
REACT_APP_DEBUG_API=false
|
||||||
|
REACT_APP_DEBUG_ROUTING=false
|
||||||
|
REACT_APP_DEBUG_AUTH=false
|
||||||
|
|
||||||
|
# Backend URL für Production (falls abweichend)
|
||||||
|
# REACT_APP_BACKEND=https://your-production-domain.com
|
||||||
18541
frontend/package-lock.json
generated
Normal file
18541
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "adspreview-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.2.5",
|
||||||
|
"antd": "^5.13.7",
|
||||||
|
"iconoir-react": "^7.11.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"react-scripts": "^5.0.1",
|
||||||
|
"sortablejs": "^1.15.6"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"start:all": "concurrently \"npm:start\" \"php -S localhost:8000 -t ../backend/public ../backend/public/index.php\""
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.0"
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:8000"
|
||||||
|
}
|
||||||
11
frontend/public/index.html
Normal file
11
frontend/public/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>adspreview</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
132
frontend/src/App.js
Normal file
132
frontend/src/App.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import { ConfigProvider, theme, Spin, Alert, Layout, App as AntApp, Tabs } from 'antd';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { getCurrentUser } from './services/api';
|
||||||
|
import UserMenu from './components/UserMenu';
|
||||||
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
|
import ClientProjects from './pages/ClientProjects';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
|
import SmartProjectRoute from './components/SmartProjectRoute';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
|
const stored = localStorage.getItem('darkMode');
|
||||||
|
return stored === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
if (token && !user) {
|
||||||
|
getCurrentUser().then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setUser(res.user);
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || 'Fehler beim Laden der Userdaten');
|
||||||
|
localStorage.removeItem('jwt');
|
||||||
|
}
|
||||||
|
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={{ algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
|
||||||
|
<AntApp>
|
||||||
|
{loading ? (
|
||||||
|
<Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Preview..." fullscreen />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && <Alert type="error" message={error} showIcon style={{ margin: 16 }} />}
|
||||||
|
<LoginPage onLogin={() => setUser(null)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
// Entferne alle ads-overview-Caches beim Logout
|
||||||
|
Object.keys(localStorage).forEach(key => {
|
||||||
|
if (key.startsWith('ads-overview-')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
localStorage.removeItem('jwt');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDarkMode = () => {
|
||||||
|
setDarkMode((prev) => {
|
||||||
|
localStorage.setItem('darkMode', !prev);
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#c792ff',
|
||||||
|
colorText: darkMode ? '#fff' : '#000',
|
||||||
|
colorBgBase: darkMode ? '#181818' : '#f5f5f5',
|
||||||
|
colorBgContainer: darkMode ? '#1f1f1f' : '#fff',
|
||||||
|
colorBorder: darkMode ? '#404040' : '#d9d9d9',
|
||||||
|
borderRadius: 4,
|
||||||
|
lineWidth: 0
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button: {
|
||||||
|
defaultBg: darkMode ? '#c792ff' : '#c792ff',
|
||||||
|
defaultHoverBg: darkMode ? '#edff5f' : '#edff5f',
|
||||||
|
defaultHoverColor: darkMode ? '#001f1e' : '#001f1e',
|
||||||
|
},
|
||||||
|
Tabs: {
|
||||||
|
itemColor: darkMode ? '#fff' : '#fff',
|
||||||
|
background: darkMode ? '#1f1f1f' : '#001f1e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntApp>
|
||||||
|
<Router>
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{/* Kein globaler Header mehr, Header wird ggf. in einzelnen Seiten eingebunden */}
|
||||||
|
<Layout.Content style={{ padding: 0 }}>
|
||||||
|
<Routes>
|
||||||
|
{user.role === 'admin' ? (
|
||||||
|
<>
|
||||||
|
<Route path="/" element={<AdminDashboard user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
|
{/* Smart URL Resolution für Admin-Zugriff auf Client-URLs - MUSS VOR der 3-Parameter Route stehen */}
|
||||||
|
<Route path=":project/:tab?" element={<SmartProjectRoute user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
|
<Route path=":client/:project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Route path="/" element={<ClientProjects user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
|
<Route path="/login" element={<LoginPage onLogin={() => setUser(null)} />} />
|
||||||
|
<Route path=":project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
|
{/* Catch-All-Route für nicht gefundene Pfade */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Routes>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</Router>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
29
frontend/src/components/ClientsManagement.js
Normal file
29
frontend/src/components/ClientsManagement.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Table, Spin } from 'antd';
|
||||||
|
import { getAll } from '../services/entityService';
|
||||||
|
|
||||||
|
export default function ClientsManagement() {
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAll('clients')
|
||||||
|
.then(data => {
|
||||||
|
const items = data.clients || {};
|
||||||
|
const list = Object.entries(items).map(([name, obj]) => ({ key: name, ...obj }));
|
||||||
|
setClients(list);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Name', dataIndex: 'key', key: 'key' },
|
||||||
|
{ title: 'Ordner', dataIndex: 'dir', key: 'dir' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Table dataSource={clients} columns={columns} pagination={false} />;
|
||||||
|
}
|
||||||
184
frontend/src/components/FilePreview.js
Normal file
184
frontend/src/components/FilePreview.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
// Hilfsfunktion für Dateigröße
|
||||||
|
export function formatFileSize(bytes) {
|
||||||
|
if (typeof bytes !== 'number' || isNaN(bytes)) return '';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion für Video-Duration
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (typeof seconds !== 'number' || isNaN(seconds)) return '';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Vorschau-Komponente mit sofortiger Zoom-Skalierung und Loading-State
|
||||||
|
export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
||||||
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
|
const width = file.width || 300;
|
||||||
|
const height = file.height || 200;
|
||||||
|
|
||||||
|
// Wrapper für saubere Skalierung
|
||||||
|
const wrapperStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
width: width * zoom,
|
||||||
|
height: height * zoom,
|
||||||
|
overflow: 'hidden',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
position: 'relative'
|
||||||
|
};
|
||||||
|
const innerStyle = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
display: 'block'
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileContent = () => {
|
||||||
|
if (file.type === 'html') {
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={innerStyle}>
|
||||||
|
{!loaded && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||||
|
borderRadius: 4
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<iframe
|
||||||
|
src={file.url}
|
||||||
|
title={file.name}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.type === 'image') {
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={innerStyle}>
|
||||||
|
{!loaded && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||||
|
borderRadius: 6
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.name}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.type === 'video') {
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={innerStyle}>
|
||||||
|
{!loaded && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||||
|
borderRadius: 6
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<video
|
||||||
|
src={file.url}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
|
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||||
|
onLoadedData={() => setLoaded(true)}
|
||||||
|
onError={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sonstige Datei: Link
|
||||||
|
return (
|
||||||
|
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginBottom: 16, marginTop: 16 }}>
|
||||||
|
{renderFileContent()}
|
||||||
|
{/* Badge für jede einzelne Datei */}
|
||||||
|
{(file.width || file.height || file.size || file.duration) && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 0,
|
||||||
|
background: darkMode ? 'rgba(31, 31, 31, 0.95)' : 'rgba(31, 31, 31, 0.95)',
|
||||||
|
color: darkMode ? '#f9f9f9' : '#f9f9f9',
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 6px',
|
||||||
|
display: 'inline-block',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
{file.width && file.height ? `${file.width}×${file.height}px` : ''}
|
||||||
|
{(file.width && file.height) && (file.size || file.duration) ? ' · ' : ''}
|
||||||
|
{file.size ? formatFileSize(file.size) : ''}
|
||||||
|
{file.size && file.duration ? ' · ' : ''}
|
||||||
|
{file.type === 'video' && file.duration ? `${formatDuration(file.duration)} s` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/SmartProjectRoute.js
Normal file
106
frontend/src/components/SmartProjectRoute.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Spin, Result, Button } from 'antd';
|
||||||
|
import { LoadingOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import ProjectDetail from '../pages/ProjectDetail';
|
||||||
|
import urlResolutionService from '../services/urlResolutionService';
|
||||||
|
import debugLogger from '../utils/debugLogger';
|
||||||
|
|
||||||
|
// Smart Route Component für Admin URL Resolution
|
||||||
|
export default function SmartProjectRoute({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||||
|
const { project, tab } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [resolvedClient, setResolvedClient] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resolveUrl = async () => {
|
||||||
|
debugLogger.routing('SmartProjectRoute - Resolving URL for:', { project, tab });
|
||||||
|
|
||||||
|
// Nur für Admins, die Client-URLs verwenden
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
debugLogger.routing('User ist kein Admin, keine URL Resolution');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugLogger.routing('Resolving client for project:', project);
|
||||||
|
const clientName = await urlResolutionService.resolveClientForProject(project);
|
||||||
|
|
||||||
|
if (clientName) {
|
||||||
|
debugLogger.success('Client resolved:', { project, clientName, tab });
|
||||||
|
setResolvedClient(clientName);
|
||||||
|
} else {
|
||||||
|
debugLogger.warn('No client found for project:', project);
|
||||||
|
setError(`Projekt "${project}" wurde keinem Client zugeordnet.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugLogger.error('Error during URL resolution:', err);
|
||||||
|
setError(`Fehler bei der URL-Resolution: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resolveUrl();
|
||||||
|
}, [project, tab, user.role]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
size="large"
|
||||||
|
tip="Löse URL auf..."
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
icon={<LockOutlined />}
|
||||||
|
status="403"
|
||||||
|
title="Keine Zugriffsberechtigung"
|
||||||
|
subTitle={error}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate('/')}>
|
||||||
|
Zurück zum Dashboard
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für Clients oder wenn keine Resolution nötig: Normale ProjectDetail
|
||||||
|
if (user.role !== 'admin' || !resolvedClient) {
|
||||||
|
return (
|
||||||
|
<ProjectDetail
|
||||||
|
user={user}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für Admins mit aufgelöster URL: ProjectDetail mit injiziertem Client-Parameter
|
||||||
|
debugLogger.routing('Passing parameters to ProjectDetail:', { client: resolvedClient, project, tab });
|
||||||
|
return (
|
||||||
|
<ProjectDetail
|
||||||
|
user={user}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
|
// Inject resolved client parameter
|
||||||
|
overrideParams={{ client: resolvedClient, project, tab }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
frontend/src/components/UserManagement.js
Normal file
405
frontend/src/components/UserManagement.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Table, Button, Spin, Alert, Modal, Form, Input, Select, App, Tooltip } from 'antd';
|
||||||
|
import { EditPencil, Trash } from 'iconoir-react';
|
||||||
|
import { getAll, create, update, remove } from '../services/entityService';
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
fetchClients();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function fetchUsers() {
|
||||||
|
setLoading(true);
|
||||||
|
getAll('users').then(res => {
|
||||||
|
if (res.success && Array.isArray(res.users)) {
|
||||||
|
setUsers(res.users);
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || 'Fehler beim Laden der Benutzer');
|
||||||
|
}
|
||||||
|
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchClients() {
|
||||||
|
getAll('clients').then(res => {
|
||||||
|
if (res.success && res.clients) {
|
||||||
|
// Extrahiere Client-Verzeichnisnamen (dir) aus der Response
|
||||||
|
const clientDirs = Object.values(res.clients).map(client => client.dir);
|
||||||
|
// Entferne Duplikate falls vorhanden
|
||||||
|
const uniqueClientDirs = [...new Set(clientDirs)];
|
||||||
|
setClients(uniqueClientDirs);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('Fehler beim Laden der Clients:', err);
|
||||||
|
setClients([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser(values) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Stelle sicher, dass disallowedClients als Array gespeichert wird
|
||||||
|
if (values.disallowedClients && !Array.isArray(values.disallowedClients)) {
|
||||||
|
values.disallowedClients = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await create('users', values);
|
||||||
|
if (data.success) {
|
||||||
|
message.success('Benutzer angelegt');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
message.error(data.error?.message || 'Fehler beim Anlegen');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error('Netzwerkfehler');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteUser(user) {
|
||||||
|
modal.confirm({
|
||||||
|
title: `Benutzer wirklich löschen?`,
|
||||||
|
content: `Soll der Benutzer "${user.username}" wirklich gelöscht werden?`,
|
||||||
|
okText: 'Löschen',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
onOk: async () => {
|
||||||
|
const data = await remove('users', user.id);
|
||||||
|
if (data.success) {
|
||||||
|
message.success('Benutzer gelöscht');
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
message.error(data.error?.message || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(user) {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditUser(values) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Separater API-Call für Passwort-Update falls angegeben
|
||||||
|
if (values.password) {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const passwordResponse = await fetch(`/api/admin/users/${editingUser.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password: values.password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordResult = await passwordResponse.json();
|
||||||
|
if (!passwordResult.success) {
|
||||||
|
message.error(passwordResult.error?.message || 'Fehler beim Ändern des Passworts');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard User-Daten aktualisieren (ohne Passwort)
|
||||||
|
const { password, confirmPassword, ...userData } = values;
|
||||||
|
|
||||||
|
// Stelle sicher, dass disallowedClients als Array gespeichert wird
|
||||||
|
if (userData.disallowedClients && !Array.isArray(userData.disallowedClients)) {
|
||||||
|
userData.disallowedClients = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await update('users', editingUser.id, userData);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
message.success('Benutzer erfolgreich aktualisiert' + (values.password ? ' (inklusive Passwort)' : ''));
|
||||||
|
setEditModalOpen(false);
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
message.error(data.error?.message || 'Fehler beim Bearbeiten');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error('Netzwerkfehler');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Benutzername', dataIndex: 'username', key: 'username' },
|
||||||
|
{ title: 'Rolle', dataIndex: 'role', key: 'role' },
|
||||||
|
{ title: 'E-Mail', dataIndex: 'email', key: 'email' },
|
||||||
|
{
|
||||||
|
title: 'Gesperrte Clients',
|
||||||
|
key: 'disallowedClients',
|
||||||
|
render: (_, record) => {
|
||||||
|
if (record.role !== 'admin' || !record.disallowedClients || record.disallowedClients.length === 0) {
|
||||||
|
return <span style={{ color: '#888', fontStyle: 'italic' }}>Keine</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{record.disallowedClients.map(client => (
|
||||||
|
<span
|
||||||
|
key={client}
|
||||||
|
style={{
|
||||||
|
background: '#ff4d4f',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{client}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Aktionen',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_, record) => (
|
||||||
|
<span style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Tooltip title="Benutzer bearbeiten">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditPencil />}
|
||||||
|
onClick={() => openEditModal(record)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Benutzer löschen">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<Trash />}
|
||||||
|
onClick={() => handleDeleteUser(record)}
|
||||||
|
danger
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin tip="Lade Benutzer..." fullscreen />;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <Alert type="error" message={error} showIcon />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{modalContextHolder}
|
||||||
|
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => setModalOpen(true)}>
|
||||||
|
Neuen Benutzer anlegen
|
||||||
|
</Button>
|
||||||
|
<Table columns={columns} dataSource={users} rowKey="id" />
|
||||||
|
<Modal
|
||||||
|
title="Neuen Benutzer anlegen"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreateUser}>
|
||||||
|
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
|
||||||
|
<Input autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
|
||||||
|
<Select placeholder="Rolle wählen">
|
||||||
|
<Option value="admin">Admin</Option>
|
||||||
|
<Option value="client">Client</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Client-Berechtigungen nur für Admins anzeigen */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('role') === 'admin' ? (
|
||||||
|
<Form.Item
|
||||||
|
name="disallowedClients"
|
||||||
|
label="Gesperrte Clients"
|
||||||
|
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="Clients auswählen, die gesperrt werden sollen"
|
||||||
|
options={clients.map(client => ({
|
||||||
|
label: client,
|
||||||
|
value: client
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="Passwort"
|
||||||
|
rules={[
|
||||||
|
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="Passwort eingeben (optional)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title="Benutzer bearbeiten"
|
||||||
|
open={editModalOpen}
|
||||||
|
onCancel={() => setEditModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
key={editingUser?.id}
|
||||||
|
initialValues={{
|
||||||
|
username: editingUser?.username,
|
||||||
|
email: editingUser?.email,
|
||||||
|
role: editingUser?.role,
|
||||||
|
disallowedClients: editingUser?.disallowedClients || []
|
||||||
|
// password und confirmPassword werden bewusst nicht gesetzt, damit sie leer beginnen
|
||||||
|
}}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleEditUser}
|
||||||
|
>
|
||||||
|
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
|
||||||
|
<Select placeholder="Rolle wählen">
|
||||||
|
<Option value="admin">Admin</Option>
|
||||||
|
<Option value="client">Client</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Client-Berechtigungen nur für Admins anzeigen */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('role') === 'admin' ? (
|
||||||
|
<Form.Item
|
||||||
|
name="disallowedClients"
|
||||||
|
label="Gesperrte Clients"
|
||||||
|
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="Clients auswählen, die gesperrt werden sollen"
|
||||||
|
options={clients.map(client => ({
|
||||||
|
label: client,
|
||||||
|
value: client
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, marginBottom: 16, borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
|
||||||
|
<h4>Passwort ändern (optional)</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="Neues Passwort"
|
||||||
|
rules={[
|
||||||
|
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="Neues Passwort eingeben (leer lassen für keine Änderung)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Passwort bestätigen"
|
||||||
|
dependencies={['password']}
|
||||||
|
rules={[
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
const password = getFieldValue('password');
|
||||||
|
if (!password && !value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (password && !value) {
|
||||||
|
return Promise.reject(new Error('Bitte Passwort bestätigen'));
|
||||||
|
}
|
||||||
|
if (password && value && password !== value) {
|
||||||
|
return Promise.reject(new Error('Passwörter stimmen nicht überein'));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="Passwort wiederholen" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||||
|
Benutzer speichern
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
frontend/src/components/UserMenu.js
Normal file
88
frontend/src/components/UserMenu.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Dropdown, Menu, Avatar, Button, Switch, message } from 'antd';
|
||||||
|
import { UserOutlined, SettingOutlined, LogoutOutlined, ReloadOutlined, MoonOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export default function UserMenu({ user, onLogout, darkMode, onToggleDarkMode, sortMode, setSortMode, saving }) {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
// Funktion zum Leeren aller ads-overview-Caches
|
||||||
|
function clearAdsCache() {
|
||||||
|
let removed = 0;
|
||||||
|
Object.keys(localStorage).forEach(key => {
|
||||||
|
if (key.startsWith('ads-overview-')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (removed > 0) {
|
||||||
|
messageApi.success({ content: 'Cache erfolgreich geleert!', duration: 2 });
|
||||||
|
} else {
|
||||||
|
messageApi.info({ content: 'Kein Cache gefunden.', duration: 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const menu = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'darkmode',
|
||||||
|
icon: <MoonOutlined />,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span>Darkmode</span>
|
||||||
|
<Switch checked={darkMode} onChange={onToggleDarkMode} size="small" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
// Sortierfunktion als Menüpunkt
|
||||||
|
{
|
||||||
|
key: 'sortmode',
|
||||||
|
icon: <RetweetOutlined />,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span>Sortieren</span>
|
||||||
|
<Switch
|
||||||
|
checked={!!sortMode}
|
||||||
|
onChange={setSortMode}
|
||||||
|
loading={!!saving}
|
||||||
|
size="small"
|
||||||
|
disabled={typeof sortMode === 'undefined' || typeof setSortMode !== 'function'}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: typeof sortMode === 'undefined' || typeof setSortMode !== 'function'
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'clearcache',
|
||||||
|
icon: <ReloadOutlined />,
|
||||||
|
label: 'Cache leeren',
|
||||||
|
onClick: clearAdsCache,
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: 'Einstellungen (bald)',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: 'Ausloggen',
|
||||||
|
onClick: onLogout,
|
||||||
|
},
|
||||||
|
].filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<Dropdown menu={menu} placement="bottomRight">
|
||||||
|
{/* <Button type="text" style={{ padding: 0, height: 40 }}> */}
|
||||||
|
{/* {user.username || user.client} */}
|
||||||
|
<Avatar style={{ backgroundColor: '#c792ff', color: '#001f1e', marginRight: 8 }} icon={<UserOutlined />} />
|
||||||
|
{/* </Button> */}
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/global.css
Normal file
17
frontend/src/global.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.ant-tabs {
|
||||||
|
border-radius: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs > .ant-tabs-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0px;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 9px 32px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-content {
|
||||||
|
padding: 0px 32px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
12
frontend/src/index.js
Normal file
12
frontend/src/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
|
import './global.css';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
47
frontend/src/pages/AdminDashboard.js
Normal file
47
frontend/src/pages/AdminDashboard.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Row, Col, Layout } from 'antd';
|
||||||
|
import UserManagement from '../components/UserManagement';
|
||||||
|
import UserMenu from '../components/UserMenu';
|
||||||
|
import ClientsManagement from '../components/ClientsManagement';
|
||||||
|
|
||||||
|
export default function AdminDashboard({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: darkMode ? '#1f1f1f' : '#fff',
|
||||||
|
padding: '0 32px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Admin Dashboard</div>
|
||||||
|
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content style={{ padding: 32 }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={16}>
|
||||||
|
<Card title="Benutzerverwaltung" variant="outlined">
|
||||||
|
<UserManagement />
|
||||||
|
</Card>
|
||||||
|
<Card title="Clients" variant="outlined" style={{ marginTop: 16 }}>
|
||||||
|
<ClientsManagement />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card title="Systeminfos" variant="outlined" disabled>
|
||||||
|
Kommt bald…
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card title="Einstellungen" variant="outlined" disabled>
|
||||||
|
Kommt bald…
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
frontend/src/pages/ClientProjects.js
Normal file
303
frontend/src/pages/ClientProjects.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Card, Row, Col, Spin, Alert, Image, Layout, Switch, App } from 'antd';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getClientProjects } from '../services/projectService';
|
||||||
|
import UserMenu from '../components/UserMenu';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
|
||||||
|
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||||
|
|
||||||
|
export default function ClientProjects({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [sortMode, setSortMode] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const normalProjectsRef = useRef(null);
|
||||||
|
const heProjectsRef = useRef(null);
|
||||||
|
const normalSortableRef = useRef(null);
|
||||||
|
const heSortableRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
getClientProjects(token).then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setProjects(res.projects);
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || 'Fehler beim Laden der Projekte');
|
||||||
|
}
|
||||||
|
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// SortableJS initialisieren/zerstören
|
||||||
|
useEffect(() => {
|
||||||
|
if (sortMode && normalProjectsRef.current && heProjectsRef.current) {
|
||||||
|
// Zerstöre bestehende Instanzen falls vorhanden
|
||||||
|
if (normalSortableRef.current) {
|
||||||
|
try {
|
||||||
|
normalSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
normalSortableRef.current = null;
|
||||||
|
}
|
||||||
|
if (heSortableRef.current) {
|
||||||
|
try {
|
||||||
|
heSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
heSortableRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisiere SortableJS für beide Bereiche
|
||||||
|
normalSortableRef.current = Sortable.create(normalProjectsRef.current, {
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
chosenClass: 'sortable-chosen',
|
||||||
|
dragClass: 'sortable-drag',
|
||||||
|
onEnd: handleSortEnd
|
||||||
|
});
|
||||||
|
|
||||||
|
heSortableRef.current = Sortable.create(heProjectsRef.current, {
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
chosenClass: 'sortable-chosen',
|
||||||
|
dragClass: 'sortable-drag',
|
||||||
|
onEnd: handleSortEnd
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Zerstöre SortableJS Instanzen sicher
|
||||||
|
if (normalSortableRef.current) {
|
||||||
|
try {
|
||||||
|
normalSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
normalSortableRef.current = null;
|
||||||
|
}
|
||||||
|
if (heSortableRef.current) {
|
||||||
|
try {
|
||||||
|
heSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
heSortableRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup beim Unmount
|
||||||
|
if (normalSortableRef.current) {
|
||||||
|
try {
|
||||||
|
normalSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
normalSortableRef.current = null;
|
||||||
|
}
|
||||||
|
if (heSortableRef.current) {
|
||||||
|
try {
|
||||||
|
heSortableRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
heSortableRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [sortMode]);
|
||||||
|
|
||||||
|
const handleSortEnd = async (evt) => {
|
||||||
|
// Neue Reihenfolge aus DOM extrahieren
|
||||||
|
const newOrder = [];
|
||||||
|
|
||||||
|
// Normale Projekte
|
||||||
|
if (normalProjectsRef.current) {
|
||||||
|
const normalElements = normalProjectsRef.current.children;
|
||||||
|
for (let el of normalElements) {
|
||||||
|
const projectName = el.getAttribute('data-project-name');
|
||||||
|
if (projectName) newOrder.push(projectName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HE Projekte
|
||||||
|
if (heProjectsRef.current) {
|
||||||
|
const heElements = heProjectsRef.current.children;
|
||||||
|
for (let el of heElements) {
|
||||||
|
const projectName = el.getAttribute('data-project-name');
|
||||||
|
if (projectName) newOrder.push(projectName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichere neue Reihenfolge
|
||||||
|
await saveProjectOrder(newOrder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProjectOrder = async (order) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const response = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(user?.dir)}/project-order`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ order })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
message.success('Reihenfolge gespeichert');
|
||||||
|
// Projekte neu laden um die neue Reihenfolge zu übernehmen
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const res = await getClientProjects(token);
|
||||||
|
if (res.success) {
|
||||||
|
setProjects(res.projects);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('Fehler beim Speichern: ' + (result.error?.message || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('Netzwerkfehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Projekte..." fullscreen />;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <Alert type="error" message={error} showIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekte trennen
|
||||||
|
const normalProjects = projects.filter(p => !p.isHE);
|
||||||
|
const heProjects = projects.filter(p => p.isHE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<style>{`
|
||||||
|
.sortable-ghost {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.sortable-chosen {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.sortable-drag {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
.sortable-container .ant-col {
|
||||||
|
cursor: ${sortMode ? 'grab' : 'default'};
|
||||||
|
}
|
||||||
|
.sortable-container .ant-col:active {
|
||||||
|
cursor: ${sortMode ? 'grabbing' : 'default'};
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<Layout.Header style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: darkMode ? '#001f1e' : '#001f1e',
|
||||||
|
color: darkMode ? '#fff' : '#fff',
|
||||||
|
padding: '0 32px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Übersicht</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<UserMenu
|
||||||
|
user={user}
|
||||||
|
onLogout={onLogout}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
|
sortMode={sortMode}
|
||||||
|
setSortMode={setSortMode}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content style={{ padding: 32 }}>
|
||||||
|
<Row gutter={32}>
|
||||||
|
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
|
||||||
|
<h3>Cinema</h3>
|
||||||
|
<Row
|
||||||
|
ref={normalProjectsRef}
|
||||||
|
gutter={[16, 4]}
|
||||||
|
style={{ columnGap: 8, marginLeft: 0 }}
|
||||||
|
className={sortMode ? 'sortable-container' : ''}
|
||||||
|
>
|
||||||
|
{normalProjects.length === 0 && <Col><Alert type="info" message="Keine Cinema Projekte gefunden." showIcon /></Col>}
|
||||||
|
{normalProjects.map((p, i) => {
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
key={p.name}
|
||||||
|
data-project-name={p.name}
|
||||||
|
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
|
||||||
|
>
|
||||||
|
{p.poster && (
|
||||||
|
<Link
|
||||||
|
to={`/${encodeURIComponent(p.name)}`}
|
||||||
|
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backendUrl + p.poster}
|
||||||
|
alt="Poster"
|
||||||
|
width={120}
|
||||||
|
height={178}
|
||||||
|
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
|
||||||
|
preview={false}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
|
||||||
|
<h3>Home Entertainment</h3>
|
||||||
|
<Row
|
||||||
|
ref={heProjectsRef}
|
||||||
|
gutter={[16, 4]}
|
||||||
|
style={{ columnGap: 8, marginLeft: 0 }}
|
||||||
|
className={sortMode ? 'sortable-container' : ''}
|
||||||
|
>
|
||||||
|
{heProjects.length === 0 && <Col><Alert type="info" message="Keine Home Entertainment Projekte gefunden." showIcon /></Col>}
|
||||||
|
{heProjects.map((p, i) => {
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
key={p.name}
|
||||||
|
data-project-name={p.name}
|
||||||
|
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
|
||||||
|
>
|
||||||
|
{p.poster && (
|
||||||
|
<Link
|
||||||
|
to={`/${encodeURIComponent(p.name)}`}
|
||||||
|
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backendUrl + p.poster}
|
||||||
|
alt="Poster"
|
||||||
|
width={120}
|
||||||
|
height={178}
|
||||||
|
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
|
||||||
|
preview={false}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/pages/LoginPage.js
Normal file
78
frontend/src/pages/LoginPage.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Typography, Alert, message } from 'antd';
|
||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
|
||||||
|
export default function LoginPage({ onLogin }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const darkMode = typeof window !== 'undefined' && localStorage.getItem('darkMode') === 'true';
|
||||||
|
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
localStorage.setItem('jwt', data.token);
|
||||||
|
message.success('Login erfolgreich!');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = window.location.pathname + window.location.search || '/';
|
||||||
|
}, 600);
|
||||||
|
onLogin && onLogin(data);
|
||||||
|
} else {
|
||||||
|
setError(data.error?.message || 'Login fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('Serverfehler');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
background: darkMode ? '#181818' : '#f5f5f5',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 350,
|
||||||
|
padding: 24,
|
||||||
|
background: darkMode ? '#1f1f1f' : '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: darkMode ? '0 2px 8px #222' : '0 2px 8px #eee',
|
||||||
|
color: darkMode ? '#fff' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title level={3} style={{ textAlign: 'center', color: darkMode ? '#fff' : undefined }}>Anmeldung</Title>
|
||||||
|
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />}
|
||||||
|
<Form name="login" onFinish={onFinish} layout="vertical">
|
||||||
|
<Form.Item name="username" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Benutzername (nur Admin)</span>} >
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="Benutzername" autoComplete="username" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Passwort</span>} rules={[{ required: true, message: 'Bitte Passwort eingeben!' }]}>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="Passwort" autoComplete="current-password" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
Einloggen
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
frontend/src/pages/ProjectDetail.js
Normal file
320
frontend/src/pages/ProjectDetail.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Tabs, Button, Spin, Skeleton, Result } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, LoadingOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import UserMenu from '../components/UserMenu';
|
||||||
|
import FilePreview, { formatFileSize, formatDuration } from '../components/FilePreview';
|
||||||
|
import debugLogger from '../utils/debugLogger';
|
||||||
|
|
||||||
|
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||||
|
|
||||||
|
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
|
||||||
|
// URL-Parameter holen, mit Override-Support für Smart Resolution
|
||||||
|
const routeParams = useParams();
|
||||||
|
const params = overrideParams || routeParams;
|
||||||
|
const { client: routeClient, project, tab } = params;
|
||||||
|
const client = user.role === 'admin' ? routeClient : user.client;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
debugLogger.routing('ProjectDetail - Received params:', {
|
||||||
|
routeParams,
|
||||||
|
overrideParams,
|
||||||
|
finalParams: params,
|
||||||
|
extractedValues: { client, project, tab }
|
||||||
|
});
|
||||||
|
|
||||||
|
// State für Fehlerstatus
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Hole Projekt-Logo (und ggf. weitere Metadaten)
|
||||||
|
const [projectLogo, setProjectLogo] = useState(null);
|
||||||
|
|
||||||
|
// Zoom-Logik (muss im Component-Scope stehen, nicht in useEffect!)
|
||||||
|
const ZOOM_LEVELS = [0.15, 0.25, 0.5, 0.75, 1];
|
||||||
|
const [zoom, setZoom] = useState(() => {
|
||||||
|
const z = parseFloat(localStorage.getItem('adsZoom') || '1');
|
||||||
|
return ZOOM_LEVELS.includes(z) ? z : 1;
|
||||||
|
});
|
||||||
|
function handleZoom(dir) {
|
||||||
|
setZoom(z => {
|
||||||
|
const idx = ZOOM_LEVELS.indexOf(z);
|
||||||
|
let next = z;
|
||||||
|
if (dir === 'in') next = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
|
||||||
|
if (dir === 'out') next = ZOOM_LEVELS[Math.max(0, idx - 1)];
|
||||||
|
localStorage.setItem('adsZoom', next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLogo() {
|
||||||
|
if (!client || !project) {
|
||||||
|
setProjectLogo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.success) {
|
||||||
|
const { logo } = data.data;
|
||||||
|
if (logo) setProjectLogo(logo);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setProjectLogo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLogo();
|
||||||
|
}, [client, project]);
|
||||||
|
|
||||||
|
// Tabs-Logik: Kategorien aus API laden
|
||||||
|
const [tabsData, setTabsData] = useState(null);
|
||||||
|
const [activeKey, setActiveKey] = useState(tab);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Tabs mit useMemo erstellen, damit sie sich bei Zoom-Änderung aktualisieren ohne API-Call
|
||||||
|
const tabs = React.useMemo(() => {
|
||||||
|
if (!tabsData) return [];
|
||||||
|
return buildTabsFromCategories(tabsData);
|
||||||
|
}, [tabsData, zoom]);
|
||||||
|
|
||||||
|
function buildTabsFromCategories(data) {
|
||||||
|
let categories = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
categories = data.filter(child => child.type === 'category');
|
||||||
|
} else if (data && Array.isArray(data.children)) {
|
||||||
|
categories = data.children.filter(child => child.type === 'category');
|
||||||
|
}
|
||||||
|
return categories.map(cat => ({
|
||||||
|
key: cat.title,
|
||||||
|
label: cat.title,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
|
||||||
|
cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
|
||||||
|
<div key={sub.name} style={{ marginTop: 24 }}>
|
||||||
|
<h2 style={{ marginBottom: 4 }}>{sub.name}</h2>
|
||||||
|
{/* Ads unterhalb der Subcategory anzeigen */}
|
||||||
|
{Array.isArray(sub.children) && sub.children.filter(ad => ad.type === 'ad').length > 0 ? (
|
||||||
|
sub.children.filter(ad => ad.type === 'ad').map(ad => (
|
||||||
|
<div key={ad.name} style={{ marginLeft: 0, marginBottom: 16 }}>
|
||||||
|
<div style={{ color: '#555', fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>{ad.name}</div>
|
||||||
|
{/* Dateien unterhalb des Ads anzeigen */}
|
||||||
|
{Array.isArray(ad.files) && ad.files.length > 0 ? (
|
||||||
|
<div style={{ marginLeft: 0, display: 'flex', flexWrap: 'wrap', gap: 32 }}>
|
||||||
|
{ad.files.map(file => (
|
||||||
|
<FilePreview key={file.name + '__' + zoom} file={file} zoom={zoom} darkMode={darkMode} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#bbb', marginLeft: 16, fontSize: 13 }}>Keine Dateien vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#aaa', marginLeft: 16, fontSize: 14 }}>Keine Ads vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#888', marginTop: 16 }}>Keine Subkategorien vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
|
||||||
|
if (!client || !project) {
|
||||||
|
setTabsData(null);
|
||||||
|
setActiveKey(undefined);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cacheKey = `ads-overview-${client}-${project}`;
|
||||||
|
const cacheTTL = 10 * 60 * 1000; // 10 Minuten
|
||||||
|
async function fetchTabs(forceApi = false) {
|
||||||
|
setLoading(true);
|
||||||
|
// Prüfe Cache
|
||||||
|
if (!forceApi) {
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
if (parsed && parsed.data && parsed.timestamp && Date.now() - parsed.timestamp < cacheTTL) {
|
||||||
|
// Gültiger Cache
|
||||||
|
const data = parsed.data;
|
||||||
|
setTabsData(data);
|
||||||
|
const tabsArr = buildTabsFromCategories(data);
|
||||||
|
if (tabsArr.length > 0) {
|
||||||
|
if (tab && tabsArr.some(t => t.key === tab)) {
|
||||||
|
setActiveKey(tab);
|
||||||
|
} else {
|
||||||
|
setActiveKey(tabsArr[0].key);
|
||||||
|
// Wenn Tab in URL fehlt oder ungültig, URL anpassen
|
||||||
|
if (!tab || !tabsArr.some(t => t.key === tab)) {
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||||
|
} else {
|
||||||
|
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fehler beim Parsen, ignoriere Cache und hole neu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kein Cache, abgelaufen oder Fehler: Hole neu
|
||||||
|
try {
|
||||||
|
setError(null); // Reset error state
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}/ads-overview`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// HTTP-Fehler behandeln
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError({ type: 'forbidden', message: 'Keine Zugriffsberechtigung auf dieses Projekt' });
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
setError({ type: 'notFound', message: 'Projekt wurde nicht gefunden' });
|
||||||
|
} else {
|
||||||
|
setError({ type: 'general', message: `Fehler beim Laden der Daten (${res.status})` });
|
||||||
|
}
|
||||||
|
setTabsData(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// Speichere im Cache
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: Date.now() }));
|
||||||
|
setTabsData(data);
|
||||||
|
const tabsArr = buildTabsFromCategories(data);
|
||||||
|
if (tabsArr.length > 0) {
|
||||||
|
if (tab && tabsArr.some(t => t.key === tab)) {
|
||||||
|
setActiveKey(tab);
|
||||||
|
} else {
|
||||||
|
setActiveKey(tabsArr[0].key);
|
||||||
|
if (!tab || !tabsArr.some(t => t.key === tab)) {
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||||
|
} else {
|
||||||
|
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError({ type: 'network', message: 'Netzwerkfehler beim Laden der Daten' });
|
||||||
|
setTabsData(null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
fetchTabs();
|
||||||
|
}, [user, client, project, tab]);
|
||||||
|
|
||||||
|
// Tab-Wechsel: Schreibe Tab in die URL
|
||||||
|
const handleTabChange = (key) => {
|
||||||
|
setActiveKey(key);
|
||||||
|
// Clients verwenden /:project/:tab, Admins /:client/:project/:tab
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<style>{`
|
||||||
|
.ads-details-tabs .ant-tabs-nav {
|
||||||
|
background: ${darkMode ? '#1f1f1f' : '#001f1e'};
|
||||||
|
color: ${darkMode ? '#fff' : '#fff'};
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{/* Zoom-Buttons jetzt in tabBarExtraContent.right */}
|
||||||
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />} size="large" tip="Lade Projektdaten..." fullscreen />
|
||||||
|
{!loading && (
|
||||||
|
tabs.length > 0 ? (
|
||||||
|
<Tabs
|
||||||
|
className="ads-details-tabs"
|
||||||
|
items={tabs}
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
tabBarExtraContent={{
|
||||||
|
left: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{ marginRight: projectLogo ? 12 : 0 }}
|
||||||
|
/>
|
||||||
|
{projectLogo && (
|
||||||
|
<img
|
||||||
|
src={projectLogo}
|
||||||
|
alt="Logo"
|
||||||
|
style={{ height: 38, marginRight: 32, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
right: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Button size="small" onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}>-</Button>
|
||||||
|
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button size="small" onClick={() => handleZoom('in')} disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}>+</Button>
|
||||||
|
</div>
|
||||||
|
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : error ? (
|
||||||
|
// Spezifische Fehlerbehandlung
|
||||||
|
<Result
|
||||||
|
icon={error.type === 'forbidden' ? <LockOutlined /> : undefined}
|
||||||
|
status={error.type === 'forbidden' ? '403' : error.type === 'notFound' ? '404' : 'error'}
|
||||||
|
title={
|
||||||
|
error.type === 'forbidden' ? 'Keine Zugriffsberechtigung' :
|
||||||
|
error.type === 'notFound' ? 'Projekt nicht gefunden' :
|
||||||
|
'Fehler beim Laden'
|
||||||
|
}
|
||||||
|
subTitle={error.message}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate('/')}>
|
||||||
|
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Fallback für andere Fälle ohne spezifischen Fehler
|
||||||
|
<div style={{ padding: 32, textAlign: 'center', color: '#888' }}>
|
||||||
|
<div>Keine Daten gefunden.</div>
|
||||||
|
<Button type="primary" style={{ marginTop: 24 }} onClick={() => navigate('/')}>
|
||||||
|
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ProjectDetail;
|
||||||
7
frontend/src/services/api.js
Normal file
7
frontend/src/services/api.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export async function getCurrentUser() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const res = await fetch('/api/auth/user', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
42
frontend/src/services/entityService.js
Normal file
42
frontend/src/services/entityService.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Service für generische CRUD-Operationen auf Admin-Ressourcen (users, clients)
|
||||||
|
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||||
|
|
||||||
|
function authHeaders(contentType = false) {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const headers = { 'Authorization': 'Bearer ' + token };
|
||||||
|
if (contentType) headers['Content-Type'] = 'application/json';
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAll(type) {
|
||||||
|
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(type, data) {
|
||||||
|
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(true),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(type, id, data) {
|
||||||
|
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders(true),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(type, id) {
|
||||||
|
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
6
frontend/src/services/projectService.js
Normal file
6
frontend/src/services/projectService.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function getClientProjects(token) {
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
147
frontend/src/services/urlResolutionService.js
Normal file
147
frontend/src/services/urlResolutionService.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Smart URL Resolution Service für Admin-Zugriff auf Client-URLs
|
||||||
|
import debugLogger from '../utils/debugLogger';
|
||||||
|
|
||||||
|
class URLResolutionService {
|
||||||
|
constructor() {
|
||||||
|
// Backend-URL mit Fallback auf localhost:8000
|
||||||
|
this.baseUrl = process.env.REACT_APP_BACKEND || 'http://localhost:8000';
|
||||||
|
this.cache = new Map();
|
||||||
|
this.cacheExpiry = 5 * 60 * 1000; // 5 Minuten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekt-Client-Mapping vom Backend laden
|
||||||
|
async fetchProjectClientMapping() {
|
||||||
|
debugLogger.api('Fetching project-client mapping from backend...');
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
if (!token) {
|
||||||
|
debugLogger.error('Kein JWT Token verfügbar');
|
||||||
|
throw new Error('Kein Token verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger.api('API Request to:', `${this.baseUrl}/api/admin/project-client-mapping`);
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/admin/project-client-mapping`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLogger.api('API Response Status:', response.status);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
debugLogger.error('Backend Error Response:', errorText);
|
||||||
|
throw new Error(`Backend Fehler: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
debugLogger.api('Backend Response Data:', data);
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
debugLogger.error('Backend returned success=false:', data.message);
|
||||||
|
throw new Error(data.message || 'Unbekannter Backend-Fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger.success('Mapping successfully loaded:', data.mapping);
|
||||||
|
return data.mapping || {};
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Fehler beim Laden des Projekt-Client-Mappings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gecachtes Mapping abrufen oder neu laden
|
||||||
|
async getProjectClientMapping() {
|
||||||
|
const cached = this.cache.get('projectClientMapping');
|
||||||
|
if (cached && (Date.now() - cached.timestamp) < this.cacheExpiry) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frische Daten laden und cachen
|
||||||
|
const freshData = await this.fetchProjectClientMapping();
|
||||||
|
this.cache.set('projectClientMapping', {
|
||||||
|
data: freshData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
return freshData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart URL Resolution: Finde Client für ein Projekt
|
||||||
|
async resolveClientForProject(projectName) {
|
||||||
|
debugLogger.routing('Smart URL Resolution für Projekt:', projectName);
|
||||||
|
|
||||||
|
if (!projectName) {
|
||||||
|
debugLogger.routing('Kein Projektname angegeben');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Admin (nur Admins dürfen URL Resolution verwenden)
|
||||||
|
if (!this.isAdmin()) {
|
||||||
|
debugLogger.routing('User ist kein Admin');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugLogger.api('Lade Projekt-Client-Mapping...');
|
||||||
|
const mapping = await this.getProjectClientMapping();
|
||||||
|
debugLogger.api('Erhaltenes Mapping:', mapping);
|
||||||
|
|
||||||
|
const clientName = mapping[projectName] || null;
|
||||||
|
debugLogger.success(`Projekt "${projectName}" → Client "${clientName}"`);
|
||||||
|
|
||||||
|
return clientName;
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Fehler bei URL Resolution:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob User Admin ist
|
||||||
|
isAdmin() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
debugLogger.auth('JWT Token check:', token ? 'Token vorhanden' : 'Kein Token');
|
||||||
|
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// JWT-Token parsen (vereinfacht)
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
debugLogger.auth('Token Payload:', payload);
|
||||||
|
const isAdminUser = payload.role === 'admin';
|
||||||
|
debugLogger.auth('Is Admin:', isAdminUser);
|
||||||
|
return isAdminUser;
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Token parsing error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart URL Resolution für Admin-Zugriff
|
||||||
|
async resolveAdminUrl(projectName, tab = null) {
|
||||||
|
if (!this.isAdmin()) {
|
||||||
|
return null; // Nur für Admins
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientName = await this.resolveClientForProject(projectName);
|
||||||
|
if (!clientName) {
|
||||||
|
return null; // Projekt nicht gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konstruiere Admin-URL
|
||||||
|
if (tab) {
|
||||||
|
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}/${encodeURIComponent(tab)}`;
|
||||||
|
} else {
|
||||||
|
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache leeren (z.B. bei Logout)
|
||||||
|
clearCache() {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton-Instanz exportieren
|
||||||
|
const urlResolutionService = new URLResolutionService();
|
||||||
|
export default urlResolutionService;
|
||||||
14
frontend/src/services/userService.js
Normal file
14
frontend/src/services/userService.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Service für Benutzer-API
|
||||||
|
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||||
|
|
||||||
|
export async function getAllUsers() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const res = await fetch(`${backendUrl}/api/admin/users`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weitere Funktionen wie createUser, updateUser, deleteUser können später ergänzt werden.
|
||||||
77
frontend/src/utils/debugLogger.example.js
Normal file
77
frontend/src/utils/debugLogger.example.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Beispiel für die Verwendung des globalen Debug-Loggers
|
||||||
|
* Kann als Referenz für andere Komponenten dienen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import debugLogger from '../utils/debugLogger';
|
||||||
|
|
||||||
|
// Beispiel: Verwendung in einer API-Service
|
||||||
|
class ExampleService {
|
||||||
|
async fetchData() {
|
||||||
|
debugLogger.api('Fetching data from API...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugLogger.time('API Request');
|
||||||
|
const response = await fetch('/api/data');
|
||||||
|
debugLogger.timeEnd('API Request');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
debugLogger.error('API request failed:', response.status);
|
||||||
|
throw new Error('API Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
debugLogger.success('Data fetched successfully:', data);
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Error fetching data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beispiel: Verwendung in einer React-Komponente
|
||||||
|
function ExampleComponent() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
debugLogger.group('ExampleComponent Mount');
|
||||||
|
debugLogger.routing('Component mounted with props:', { data });
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await new ExampleService().fetchData();
|
||||||
|
setData(result);
|
||||||
|
debugLogger.success('Component state updated');
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Failed to load component data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
debugLogger.groupEnd();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
debugLogger.auth('Checking authentication status');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
debugLogger.warn('No authentication token found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger.auth('Authentication token present');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Component JSX */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ExampleService, ExampleComponent };
|
||||||
121
frontend/src/utils/debugLogger.js
Normal file
121
frontend/src/utils/debugLogger.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Globale Debug-Utility für die gesamte Anwendung
|
||||||
|
* Konfigurierbar über .env Variablen
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DebugLogger {
|
||||||
|
constructor() {
|
||||||
|
// Debug-Konfiguration aus .env Datei
|
||||||
|
this.isEnabled = process.env.REACT_APP_DEBUG_ENABLED === 'true';
|
||||||
|
this.apiDebug = process.env.REACT_APP_DEBUG_API === 'true';
|
||||||
|
this.routingDebug = process.env.REACT_APP_DEBUG_ROUTING === 'true';
|
||||||
|
this.authDebug = process.env.REACT_APP_DEBUG_AUTH === 'true';
|
||||||
|
|
||||||
|
// Fallback: In Development-Umgebung standardmäßig aktiviert
|
||||||
|
if (process.env.NODE_ENV === 'development' && this.isEnabled === undefined) {
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.apiDebug = true;
|
||||||
|
this.routingDebug = true;
|
||||||
|
this.authDebug = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allgemeine Debug-Logs
|
||||||
|
log(message, ...args) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.log(`🔧 [DEBUG]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-bezogene Debug-Logs
|
||||||
|
api(message, ...args) {
|
||||||
|
if (this.isEnabled && this.apiDebug) {
|
||||||
|
console.log(`📡 [API]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing-bezogene Debug-Logs
|
||||||
|
routing(message, ...args) {
|
||||||
|
if (this.isEnabled && this.routingDebug) {
|
||||||
|
console.log(`🚀 [ROUTING]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentifizierungs-bezogene Debug-Logs
|
||||||
|
auth(message, ...args) {
|
||||||
|
if (this.isEnabled && this.authDebug) {
|
||||||
|
console.log(`🔐 [AUTH]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error-Logs (immer aktiviert, auch in Production für kritische Fehler)
|
||||||
|
error(message, ...args) {
|
||||||
|
console.error(`❌ [ERROR]`, message, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning-Logs (immer aktiviert)
|
||||||
|
warn(message, ...args) {
|
||||||
|
console.warn(`⚠️ [WARN]`, message, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success-Logs (nur wenn Debug aktiviert)
|
||||||
|
success(message, ...args) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.log(`✅ [SUCCESS]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info-Logs (nur wenn Debug aktiviert)
|
||||||
|
info(message, ...args) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.info(`ℹ️ [INFO]`, message, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppen für zusammenhängende Logs
|
||||||
|
group(title) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.group(`🔧 [DEBUG GROUP] ${title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEnd() {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitmessung für Performance-Debugging
|
||||||
|
time(label) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.time(`⏱️ [TIMER] ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeEnd(label) {
|
||||||
|
if (this.isEnabled) {
|
||||||
|
console.timeEnd(`⏱️ [TIMER] ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug-Status anzeigen
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
enabled: this.isEnabled,
|
||||||
|
api: this.apiDebug,
|
||||||
|
routing: this.routingDebug,
|
||||||
|
auth: this.authDebug,
|
||||||
|
environment: process.env.NODE_ENV
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton-Instanz exportieren
|
||||||
|
const debugLogger = new DebugLogger();
|
||||||
|
|
||||||
|
// Zeige Debug-Status beim Import
|
||||||
|
if (debugLogger.isEnabled) {
|
||||||
|
debugLogger.info('Debug Logger initialized:', debugLogger.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default debugLogger;
|
||||||
Reference in New Issue
Block a user