Compare commits

..

27 Commits
1.9.0 ... main

Author SHA1 Message Date
04b13872c9 chore: bump version to 2.0.1 2025-12-01 22:16:55 +01:00
0d24a5e74c docs: Update Text Upload Page 2025-12-01 22:14:45 +01:00
2acbc4e248 docs: Moved finisched FeatureRequest, Update README.md 2025-11-30 17:36:54 +01:00
27d8c73b5f chore: release v2.0.0
🔖 Version 2.0.0

###  Features
- ENV-Struktur massiv vereinfacht (Phase 6)
- Add consent change and deletion notifications (Phase 4)
- Add upload notifications to Telegram Bot (Phase 3)
- Add TelegramNotificationService (Phase 2)
- Add Telegram Bot standalone test (Phase 1)
- Add Telegram notification feature request and improve prod.sh Docker registry push

### 🔧 Chores
- Add package.json for Telegram test scripts
2025-11-30 14:11:19 +01:00
46198ddfdd Merge branch 'feature/telegram-notifications' 2025-11-30 14:09:51 +01:00
6b603112de docs: README.md aktualisiert - ENV-Struktur & Telegram dokumentiert
- Docker Structure: Neue ENV-Verwaltung erklärt (2 zentrale .env Dateien)
- Environment Variables: Vollständige Tabelle mit allen Variablen
- Telegram-Konfiguration dokumentiert
- Phase 6 als abgeschlossen markiert in FEATURE_PLAN-telegram.md
2025-11-30 13:26:54 +01:00
dd71dcab44 feat: ENV-Struktur massiv vereinfacht (Phase 6)
- Von 16 .env Dateien auf 2 zentrale reduziert
  * docker/dev/.env - Development Secrets
  * docker/prod/.env - Production Secrets

- Alle ENV-Variablen jetzt in docker-compose.yml environment sections
- .env COPY aus allen Dockerfiles entfernt (wurden durch volume mounts überschrieben)
- Frontend env.sh umgeschrieben: Liest ENV-Variablen statt .env Datei
- CLIENT_URL komplett entfernt (wurde nirgendwo verwendet)

- Fix: management.js nutzt platform_name statt name (DB-Schema korrekt)

ENV-Handling jetzt deutlich einfacher und wartbarer!
Von 4 Frontend ENV-Variablen auf 3 reduziert (API_URL, PUBLIC_HOST, INTERNAL_HOST)
2025-11-30 13:19:24 +01:00
d76b4b2c9c docs(telegram): complete Phase 5 documentation and security improvements
- Updated README.md with Telegram features section in 'Latest Features'
- Added Telegram environment variables to Environment Variables table
- Updated FEATURE_PLAN-telegram.md: marked Phases 1-5 as completed
- Updated status table with completion dates (Phase 1-4: done, Phase 5: docs complete)

OpenAPI Documentation:
- Added swagger tags to reorder route (Management Portal)
- Added swagger tags to consent routes (Consent Management)
- Regenerated openapi.json with correct tags (no more 'default' category)

Environment Configuration:
- Updated .env.backend.example with Telegram variables and session secret
- Created docker/dev/.env.example with Telegram configuration template
- Created docker/prod/.env.example with production environment template
- Moved secrets from docker-compose.yml to .env files (gitignored)
- Changed docker/dev/docker-compose.yml to use placeholders: ${TELEGRAM_BOT_TOKEN}

Security Enhancements:
- Disabled test message on server start by default (TELEGRAM_SEND_TEST_ON_START=false)
- Extended pre-commit hook to detect hardcoded Telegram secrets
- Hook prevents commit if TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID are hardcoded
- All secrets must use environment variable placeholders

Phase 5 fully completed and documented.
2025-11-30 11:40:59 +01:00
489e2166bb feat(telegram): add daily deletion warning cron job (Phase 5)
- Added Telegram warning cron job at 09:00 (1 hour before cleanup)
- Integrated with GroupCleanupService.findGroupsForDeletion()
- Sends sendDeletionWarning() notification for groups pending deletion
- Added manual trigger method triggerTelegramWarningNow() for development
- Added POST /api/admin/telegram/warning endpoint for manual testing
- Fixed SchedulerService singleton instance in server.js app.set()
- Added Telegram ENV vars to docker-compose.yml environment section

Tested successfully with test data showing warning message in Telegram.
2025-11-30 11:20:10 +01:00
8cceb8e9a3 feat: Add consent change and deletion notifications (Phase 4)
- Integrate sendConsentChangeNotification() into management.js PUT /consents
- Integrate sendGroupDeletedNotification() into management.js DELETE /:token
- Refactor sendConsentChangeNotification() to accept structured changeData
- Add platform name lookup for social media consent notifications
- Non-blocking async notifications (won't fail consent changes on error)

Phase 4 complete: Tested successfully with:
- Workshop consent revoke → Telegram notification received
- Group deletion → Telegram notification received

Changes:
- Workshop consent: Shows action (revoke/restore) and new status
- Social media consent: Shows platform and action
- Deletion: Shows uploader, year, title, image count
2025-11-30 10:22:52 +01:00
62be18ecaa feat: Add upload notifications to Telegram Bot (Phase 3)
- Integrate TelegramNotificationService into batchUpload route
- Send notification on successful upload with group details
- Add metadata parsing for year/title/name from form fields
- Create integration tests for upload notifications
- Fix getAdminUrl() to use INTERNAL_HOST with dev port
- Update jest.config.js to transform uuid ESM module
- Non-blocking async notification (won't fail upload on error)

Phase 3 complete: Upload notifications working in Docker dev environment
Tested successfully with real Telegram bot in test group
2025-11-29 23:47:01 +01:00
025578fa3d feat: Add TelegramNotificationService (Phase 2)
- Create TelegramNotificationService with all notification methods
- Add node-telegram-bot-api dependency
- Integrate service into server.js (auto-test on dev startup)
- Add ENV variables to docker/dev/backend/config/.env
- Create unit tests (10/14 passing - mock issues for 4)
- Update README.dev.md with Telegram testing guide

Service Features:
- sendTestMessage() - Test connection
- sendUploadNotification() - Phase 3 ready
- sendConsentChangeNotification() - Phase 4 ready
- sendGroupDeletedNotification() - Phase 4 ready
- sendDeletionWarning() - Phase 5 ready

Phase 2 complete: Backend service ready for integration.
2025-11-29 22:41:38 +01:00
15833dec83 chore: Add package.json for Telegram test scripts 2025-11-29 20:06:38 +01:00
86ace42fca feat: Add Telegram Bot standalone test (Phase 1)
- Add FEATURE_PLAN-telegram.md with 6-phase implementation roadmap
- Add scripts/README.telegram.md with step-by-step setup guide
- Add scripts/telegram-test.js standalone test script
- Add scripts/.env.telegram.example template for credentials
- Update .gitignore to protect Telegram credentials

Phase 1 complete: Bot setup, privacy mode configuration, and successful test message delivery.
2025-11-29 20:05:40 +01:00
b2386e7f11 feat: Add Telegram notification feature request and improve prod.sh Docker registry push 2025-11-29 19:28:23 +01:00
52125397bf chore: release v1.10.2
🔖 Version 1.10.2

###  Features
- Auto-push releases with --follow-tags
2025-11-29 17:47:55 +01:00
aea21622f7 feat: Auto-push releases with --follow-tags 2025-11-29 17:47:01 +01:00
bd10f6533e chore: release v1.10.1
🔖 Version 1.10.1

### 🐛 Fixes
- Update Footer.js version to 1.10.0 and fix sync-version.sh regex

### ♻️ Refactoring
- Use package.json version directly in Footer instead of env variables
2025-11-29 17:34:25 +01:00
bf26472ea3 refactor: Use package.json version directly in Footer instead of env variables 2025-11-29 17:14:24 +01:00
ec3d7ee4d0 fix: Update Footer.js version to 1.10.0 and fix sync-version.sh regex 2025-11-29 17:02:40 +01:00
8818d2987d chore: release v1.10.0
🔖 Version 1.10.0

###  Features
- Enable drag-and-drop reordering in ModerationGroupImagesPage
- Error handling system and animated error pages

### ♻️ Refactoring
- Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
- Consolidate error pages into single ErrorPage component
- Centralized styling with CSS and global MUI overrides

### 🔧 Chores
- Improve release script with tag-based commit detection
2025-11-29 16:57:14 +01:00
40aa546498 chore: Improve release script with tag-based commit detection
- Add helpful warning when no previous tag exists
- Show which tag is being used for commit range
- Provide tip for creating retroactive tags
- Fix typo in git log command (--online -> --oneline)
2025-11-29 16:52:19 +01:00
e4712f9e7e refactor: Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
- Created ConsentFilter component with proper styling
- Created StatsDisplay component for statistics display
- Added ModerationGroupsPage.css to remove inline styles
- Removed 83 lines of inline CSS from ModerationGroupsPage
- Components now reusable across admin pages
- Added container wrappers and titles to both components
- Improved code maintainability and separation of concerns
2025-11-29 15:21:51 +01:00
e4a76a6b3d refactor: Consolidate error pages into single ErrorPage component
- Created generic ErrorPage.js with errorCode prop
- Centralized error messages in ERROR_MESSAGES dictionary
- Updated App.js to use ErrorPage for all error routes
- Updated ErrorBoundary.js to use new ErrorPage component
- Removed duplicate files: 403Page.js, 404Page.js, 500Page.js, 502Page.js, 503Page.js
- Fixed 403/404 routing: protected routes show 403, unknown routes show 404
- Error pages now vertically centered with min-height: 100vh
2025-11-29 12:17:51 +01:00
91d6d06687 feat: Enable drag-and-drop reordering in ModerationGroupImagesPage
- Added PUT /api/admin/groups/:groupId/reorder endpoint
- Implemented handleReorder in ModerationGroupImagesPage
- Uses adminRequest API with proper error handling
- Same mobile touch support as ManagementPortalPage
2025-11-27 20:09:08 +01:00
215acaa67f refactor: Centralized styling with CSS and global MUI overrides
- Migrated all Pages from Material-UI to HTML+CSS (GroupsOverviewPage, ManagementPortalPage, ModerationGroupImagesPage, ModerationGroupsPage, PublicGroupImagesPage, SlideshowPage, MultiUploadPage)
- Added comprehensive typography system in App.css (h1-h3, p, utility classes)
- Added global Material-UI font overrides for Open Sans
- Removed redundant fontFamily: 'roboto' from all components
- Fixed button alignment in ImageGalleryCard (margin-top: auto)
- Removed emojis from titles for cleaner UI
- Standardized button padding (12px 30px) across application
- Improved code consistency and maintainability with centralized CSS approach
2025-11-27 19:47:39 +01:00
25dda32c4e feat: Error handling system and animated error pages
- Add ErrorBoundary component for React error handling
- Create animated error pages (403, 404, 500, 502, 503)
- Implement ErrorAnimation component with seven-segment display
- Add apiClient (axios) and apiFetch (fetch) wrappers with automatic error page redirects
- Migrate critical API calls to use new error handling
- Update font from Roboto to Open Sans across all components
- Remove unused CLIENT_URL from docker-compose files
- Rename 404Page.css to ErrorPage.css for consistency
- Add comprehensive ERROR_HANDLING.md documentation
2025-11-26 22:42:55 +01:00
91 changed files with 35027 additions and 817 deletions

5
.gitignore vendored
View File

@ -9,6 +9,11 @@ node_modules/
.env
.env.local
# Telegram credentials
scripts/.env.telegram
scripts/node_modules/
scripts/package-lock.json
# IDE
.vscode/
.idea/

View File

@ -1,6 +1,53 @@
# Changelog
## [Unreleased] - Branch: feature/public-internal-hosts
## [2.0.1] - 2025-12-01
## [2.0.0] - 2025-11-30
### ✨ Features
- ENV-Struktur massiv vereinfacht (Phase 6)
- Add consent change and deletion notifications (Phase 4)
- Add upload notifications to Telegram Bot (Phase 3)
- Add TelegramNotificationService (Phase 2)
- Add Telegram Bot standalone test (Phase 1)
- Add Telegram notification feature request and improve prod.sh Docker registry push
### 🔧 Chores
- Add package.json for Telegram test scripts
## [1.10.2] - 2025-11-29
### ✨ Features
- Auto-push releases with --follow-tags
## [1.10.1] - 2025-11-29
### 🐛 Fixes
- Update Footer.js version to 1.10.0 and fix sync-version.sh regex
### ♻️ Refactoring
- Use package.json version directly in Footer instead of env variables
## [1.10.0] - 2025-11-29
### ✨ Features
- Enable drag-and-drop reordering in ModerationGroupImagesPage
- Error handling system and animated error pages
### ♻️ Refactoring
- Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
- Consolidate error pages into single ErrorPage component
- Centralized styling with CSS and global MUI overrides
### 🔧 Chores
- Improve release script with tag-based commit detection
## Public/Internal Host Separation (November 25, 2025)
### 🌐 Public/Internal Host Separation (November 25, 2025)
@ -99,7 +146,7 @@
---
## [Unreleased] - Branch: feature/security
## feature/security
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
@ -121,7 +168,7 @@
---
## [Unreleased] - Branch: feature/SocialMedia
## feature/SocialMedia
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
@ -370,7 +417,7 @@
---
## [Unreleased] - Branch: feature/PreloadImage
## Preload Image
### 🚀 Slideshow Optimization (November 2025)
@ -407,7 +454,7 @@
---
## [Unreleased] - Branch: feature/DeleteUnprovedGroups
## Delete Unproved Groups
### ✨ Automatic Cleanup Feature (November 2025)
@ -474,7 +521,7 @@
---
## [Unreleased] - Branch: feature/ImageDescription
## Image Description
### ✨ Image Descriptions Feature (November 2025)
@ -548,7 +595,7 @@
---
## [Unreleased] - Branch: upgrade/deps-react-node-20251028
## Upgrade Deps: React & Node (October 2025)
### 🎯 Major Framework Upgrades (October 2025)

View File

@ -0,0 +1,385 @@
# Feature Plan: Telegram Bot Integration
## Übersicht
Implementierung eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
**Basis:** [FEATURE_REQUEST-telegram.md](./FEATURE_REQUEST-telegram.md)
---
## Phasen-Aufteilung
### Phase 1: Bot Setup & Standalone-Test
**Ziel:** Telegram Bot erstellen und isoliert testen (ohne App-Integration)
**Status:** 🟢 Abgeschlossen
**Deliverables:**
- [x] Telegram Bot via BotFather erstellt
- [x] Bot zu Test-Telegram-Gruppe hinzugefügt
- [x] Chat-ID ermittelt
- [x] `scripts/telegram-test.js` - Standalone Test-Script
- [x] `scripts/README.telegram.md` - Setup-Anleitung
- [x] `.env.telegram` - Template für Bot-Credentials
- [x] Erfolgreiche Test-Nachricht versendet
**Akzeptanzkriterium:**
✅ Bot sendet erfolgreich Nachricht an Testgruppe
---
### Phase 2: Backend-Service Integration
**Ziel:** TelegramNotificationService in Backend integrieren
**Status:** 🟢 Abgeschlossen
**Dependencies:** Phase 1 abgeschlossen
**Deliverables:**
- [x] `backend/src/services/TelegramNotificationService.js`
- [x] ENV-Variablen in `docker/dev/backend/config/.env`
- [x] Unit-Tests für Service
- [x] Docker Dev Environment funktioniert
---
### Phase 3: Upload-Benachrichtigungen
**Ziel:** Automatische Benachrichtigungen bei neuem Upload
**Status:** 🟢 Abgeschlossen
**Dependencies:** Phase 2 abgeschlossen
**Deliverables:**
- [x] Integration in `routes/batchUpload.js`
- [x] `sendUploadNotification()` Methode
- [x] Formatierung mit Icons/Emojis
- [x] Integration-Tests
---
### Phase 4: User-Änderungs-Benachrichtigungen
**Ziel:** Benachrichtigungen bei Consent-Änderungen & Löschungen
**Status:** 🟢 Abgeschlossen
**Dependencies:** Phase 3 abgeschlossen
**Deliverables:**
- [x] Integration in `routes/management.js` (PUT/DELETE)
- [x] `sendConsentChangeNotification()` Methode
- [x] `sendGroupDeletedNotification()` Methode
- [x] Integration-Tests
---
### Phase 5: Tägliche Lösch-Warnungen
**Ziel:** Cron-Job für bevorstehende Löschungen
**Status:** 🟢 Abgeschlossen
**Dependencies:** Phase 4 abgeschlossen
**Deliverables:**
- [x] Cron-Job Setup (node-cron)
- [x] `sendDeletionWarning()` Methode
- [x] Admin-Route für manuellen Trigger (`POST /api/admin/telegram/warning`)
- [x] SchedulerService Integration (09:00 daily)
- [x] Docker ENV-Variablen konfiguriert
- [x] README.md Update
---
### Phase 6: Production Deployment
**Ziel:** Rollout in Production-Umgebung + ENV-Vereinfachung
**Status:** 🟢 Abgeschlossen
**Dependencies:** Phase 1-5 abgeschlossen + getestet
**Deliverables:**
- [x] ENV-Struktur vereinfachen (zu viele .env-Dateien!)
- [x] Production ENV-Variablen in docker/prod/.env konfigurieren
- [x] docker/prod/docker-compose.yml mit Telegram-ENV erweitern
- [x] Consent-Änderung Bug Fix (platform_name statt name)
- [x] README.md Update mit ENV-Struktur Dokumentation
- ⏭️ Bot in echte Werkstatt-Gruppe einfügen (optional, bei Bedarf)
- ⏭️ Production Testing (optional, bei Bedarf)
**ENV-Vereinfachung (Abgeschlossen):**
```
Vorher: 16 .env-Dateien mit redundanter Konfiguration
Nachher: 2 zentrale .env-Dateien
✅ docker/dev/.env (alle dev secrets)
✅ docker/prod/.env (alle prod secrets)
✅ docker-compose.yml nutzt ${VAR} Platzhalter
✅ Gemountete .env-Dateien entfernt (wurden überschrieben)
✅ Alle ENV-Variablen in docker-compose environment
```
---
## Phase 1 - Detaillierter Plan
### 1. Vorbereitung (5 min)
**Auf Windows 11 Host-System:**
```bash
# Node.js Version prüfen
node --version # Sollte >= 18.x sein
# Projektverzeichnis öffnen
cd /home/lotzm/gitea.hobbyhimmel/Project-Image-Uploader/scripts
# Dependencies installieren (lokal)
npm init -y # Falls noch keine package.json
npm install node-telegram-bot-api dotenv
```
### 2. Telegram Bot erstellen (10 min)
**Anleitung:** Siehe `scripts/README.telegram.md`
**Schritte:**
1. Telegram öffnen (Windows 11 App)
2. [@BotFather](https://t.me/botfather) suchen
3. `/newbot` Command
4. Bot-Name: "Werkstatt Image Uploader Bot"
5. Username: `werkstatt_uploader_bot` (oder verfügbar)
6. **Token kopieren**`.env.telegram`
### 3. Test-Gruppe erstellen & Bot hinzufügen (5 min)
**Schritte:**
1. Neue Telegram-Gruppe erstellen: "Werkstatt Upload Bot Test"
2. Bot zur Gruppe hinzufügen: @werkstatt_uploader_bot
3. **Chat-ID ermitteln** (siehe README.telegram.md)
4. Chat-ID speichern → `.env.telegram`
### 4. Test-Script erstellen (10 min)
**Datei:** `scripts/telegram-test.js`
**Features:**
- Lädt `.env.telegram`
- Validiert Bot-Token
- Sendet Test-Nachricht
- Error-Handling
### 5. Erste Nachricht senden (2 min)
```bash
cd scripts
node telegram-test.js
```
**Erwartete Ausgabe:**
```
✅ Telegram Bot erfolgreich verbunden!
Bot-Name: Werkstatt Image Uploader Bot
Bot-Username: @werkstatt_uploader_bot
📤 Sende Test-Nachricht an Chat -1001234567890...
✅ Nachricht erfolgreich gesendet!
```
**In Telegram-Gruppe:**
```
🤖 Telegram Bot Test
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
Status: ✅ Erfolgreich verbunden!
Zeitstempel: 2025-11-29 14:23:45
```
---
## Dateistruktur (Phase 1)
```
scripts/
├── README.telegram.md # Setup-Anleitung (NEU)
├── telegram-test.js # Test-Script (NEU)
├── .env.telegram.example # ENV-Template (NEU)
├── .env.telegram # Echte Credentials (gitignored, NEU)
├── package.json # Lokale Dependencies (NEU)
└── node_modules/ # npm packages (gitignored)
```
---
## Environment Variables (Phase 1)
**Datei:** `scripts/.env.telegram`
```bash
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=-1001234567890
```
---
## Dependencies (Phase 1)
**Package:** `scripts/package.json`
```json
{
"name": "telegram-test-scripts",
"version": "1.0.0",
"description": "Standalone Telegram Bot Testing",
"main": "telegram-test.js",
"scripts": {
"test": "node telegram-test.js"
},
"dependencies": {
"node-telegram-bot-api": "^0.66.0",
"dotenv": "^16.3.1"
}
}
```
---
## Sicherheit (Phase 1)
**`.gitignore` ergänzen:**
```
# Telegram Credentials
scripts/.env.telegram
scripts/node_modules/
scripts/package-lock.json
```
**Wichtig:**
- ❌ Niemals `.env.telegram` committen!
- ✅ Nur `.env.telegram.example` (ohne echte Tokens) committen
- ✅ Bot-Token regenerieren, falls versehentlich exposed
---
## Testing Checklist (Phase 1)
- [x] Node.js Version >= 18.x
- [x] Telegram App installiert (Windows 11)
- [x] Bot via BotFather erstellt
- [x] Bot-Token gespeichert in `.env.telegram`
- [x] Test-Gruppe erstellt
- [x] Bot zur Gruppe hinzugefügt
- [x] Chat-ID ermittelt
- [x] Chat-ID gespeichert in `.env.telegram`
- [x] Privacy Mode deaktiviert
- [x] Test-Nachricht erfolgreich gesendet
- [ ] `npm install` erfolgreich
- [ ] `node telegram-test.js` läuft ohne Fehler
- [ ] Test-Nachricht in Telegram-Gruppe empfangen
- [ ] Formatierung (Emojis, Zeilenumbrüche) korrekt
---
## Troubleshooting (Phase 1)
### Problem: "Unauthorized (401)"
**Lösung:** Bot-Token falsch → BotFather prüfen, `.env.telegram` korrigieren
### Problem: "Bad Request: chat not found"
**Lösung:** Chat-ID falsch → Neue Nachricht in Gruppe senden, Chat-ID neu ermitteln
### Problem: "ETELEGRAM: 403 Forbidden"
**Lösung:** Bot wurde aus Gruppe entfernt → Bot erneut zur Gruppe hinzufügen
### Problem: "Module not found: node-telegram-bot-api"
**Lösung:**
```bash
cd scripts
npm install
```
---
## Nächste Schritte (nach Phase 1)
1. **Code-Review:** `scripts/telegram-test.js`
2. **Dokumentation Review:** `scripts/README.telegram.md`
3. **Commit:**
```bash
git add scripts/
git commit -m "feat: Add Telegram Bot standalone test (Phase 1)"
```
4. **Phase 2 starten:** Backend-Integration planen
---
## Zeitschätzung
| Phase | Aufwand | Beschreibung |
|-------|---------|--------------|
| **Phase 1** | **~45 min** | Bot Setup + Standalone-Test |
| Phase 2 | ~2h | Backend-Service |
| Phase 3 | ~2h | Upload-Benachrichtigungen |
| Phase 4 | ~2h | Änderungs-Benachrichtigungen |
| Phase 5 | ~2h | Cron-Job |
| Phase 6 | ~1h | Production Deployment |
| **Gesamt** | **~9-10h** | Vollständige Integration |
---
## Conventional Commits (ab Phase 1)
**Phase 1:**
```bash
git commit -m "feat: Add Telegram Bot test script"
git commit -m "docs: Add Telegram Bot setup guide"
git commit -m "chore: Add node-telegram-bot-api dependency to scripts"
```
**Phase 2:**
```bash
git commit -m "feat: Add TelegramNotificationService"
git commit -m "test: Add TelegramNotificationService unit tests"
```
**Phase 3-6:**
```bash
git commit -m "feat: Add upload notification to Telegram"
git commit -m "feat: Add consent change notifications"
git commit -m "feat: Add daily deletion warnings cron job"
git commit -m "docs: Update README with Telegram features"
```
---
## Release-Planung
**Phase 1:** Kein Release (interne Tests)
**Phase 6 (Final):**
- **Version:** `2.0.0` (Major Release)
- **Branch:** `feature/telegram-notifications`
- **Release-Command:** `npm run release:major`
---
## Status-Tracking
**Letzte Aktualisierung:** 2025-11-30
| Phase | Status | Datum |
|-------|--------|-------|
| Phase 1 | 🟢 Abgeschlossen | 2025-11-29 |
| Phase 2 | 🟢 Abgeschlossen | 2025-11-29 |
| Phase 3 | 🟢 Abgeschlossen | 2025-11-29 |
| Phase 4 | 🟢 Abgeschlossen | 2025-11-30 |
| Phase 5 | 🟢 Abgeschlossen | 2025-11-30 |
| Phase 6 | 🟡 ENV vereinfacht | 2025-11-30 |
**Legende:**
- 🟢 Abgeschlossen
- 🟡 In Arbeit
- 🔴 Blockiert
- ⚪ Ausstehend

View File

@ -0,0 +1,450 @@
# Feature Request: Telegram Bot für Benachrichtigungen
## Übersicht
Integration eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
## Ziel
Werkstatt-Mitarbeiter sollen zeitnah über neue Uploads, Änderungen und bevorstehende Löschungen informiert werden, ohne ständig das Admin-Panel prüfen zu müssen.
## Use Case
Die Offene Werkstatt hat eine Telegram Gruppe, in der das Team kommuniziert. Der Bot wird zu dieser Gruppe hinzugefügt und sendet automatisierte Benachrichtigungen bei relevanten Events.
## Funktionale Anforderungen
### 1. Benachrichtigung: Neuer Upload
**Trigger:** Erfolgreicher Batch-Upload über `/api/upload-batch`
**Nachricht enthält:**
- 📸 Upload-Icon
- Name des Uploaders
- Anzahl der hochgeladenen Bilder
- Jahr der Gruppe
- Titel der Gruppe
- Workshop-Consent Status (✅ Ja / ❌ Nein)
- Social Media Consents (Facebook, Instagram, TikTok Icons)
- Link zum Admin-Panel (Moderation)
**Beispiel:**
```
📸 Neuer Upload!
Uploader: Max Mustermann
Bilder: 12
Gruppe: 2024 - Schweißkurs November
Workshop: ✅ Ja
Social Media: 📘 Instagram, 🎵 TikTok
🔗 Zur Freigabe: https://internal.hobbyhimmel.de/moderation
```
### 2. Benachrichtigung: User-Änderungen
**Trigger:**
- `PUT /api/manage/:token` (Consent-Änderung)
- `DELETE /api/manage/:token/groups/:groupId` (Gruppenl löschung durch User)
**Nachricht enthält:**
- ⚙️ Änderungs-Icon
- Art der Änderung (Consent Update / Gruppe gelöscht)
- Betroffene Gruppe (Jahr + Titel)
- Uploader-Name
- Neue Consent-Werte (bei Update)
**Beispiel (Consent-Änderung):**
```
⚙️ User-Änderung
Aktion: Consent aktualisiert
Gruppe: 2024 - Schweißkurs November
Uploader: Max Mustermann
Neu:
Workshop: ❌ Nein (vorher: ✅)
Social Media: 📘 Instagram (TikTok entfernt)
🔗 Details: https://internal.hobbyhimmel.de/moderation
```
**Beispiel (Gruppe gelöscht):**
```
⚙️ User-Änderung
Aktion: Gruppe gelöscht
Gruppe: 2024 - Schweißkurs November
Uploader: Max Mustermann
Bilder: 12
User hat Gruppe selbst über Management-Link gelöscht
```
### 3. Benachrichtigung: Ablauf Freigabe / Löschung in 1 Tag
**Trigger:** Täglicher Cron-Job (z.B. 09:00 Uhr)
**Prüfung:**
- Alle nicht-freigegebenen Gruppen mit `created_at < NOW() - 6 days`
- Werden in 24 Stunden durch Cleanup-Service gelöscht
**Nachricht enthält:**
- ⏰ Warnung-Icon
- Liste aller betroffenen Gruppen
- Countdown bis Löschung
- Hinweis auf Freigabe-Möglichkeit
**Beispiel:**
```
⏰ Löschung in 24 Stunden!
Folgende Gruppen werden morgen automatisch gelöscht:
1. 2024 - Schweißkurs November
Uploader: Max Mustermann
Bilder: 12
Hochgeladen: 20.11.2024
2. 2024 - Holzarbeiten Workshop
Uploader: Anna Schmidt
Bilder: 8
Hochgeladen: 21.11.2024
💡 Jetzt freigeben oder Freigabe bleibt aus!
🔗 Zur Moderation: https://internal.hobbyhimmel.de/moderation
```
## Technische Anforderungen
### Backend-Integration
**Neue Umgebungsvariablen:**
```bash
TELEGRAM_BOT_TOKEN=<bot-token>
TELEGRAM_CHAT_ID=<werkstatt-gruppen-id>
TELEGRAM_ENABLED=true
```
**Neue Service-Datei:** `backend/src/services/TelegramNotificationService.js`
**Methoden:**
- `sendUploadNotification(groupData)`
- `sendConsentChangeNotification(oldConsents, newConsents, groupData)`
- `sendGroupDeletedNotification(groupData)`
- `sendDeletionWarning(groupsList)`
**Integration Points:**
- `routes/batchUpload.js` → Nach erfolgreichem Upload
- `routes/management.js` → PUT/DELETE Endpoints
- `services/GroupCleanupService.js` → Neue Methode für tägliche Prüfung
### Telegram Bot Setup
**Bot erstellen:**
1. Mit [@BotFather](https://t.me/botfather) sprechen
2. `/newbot` → Bot-Name: "Werkstatt Image Uploader Bot"
3. Token speichern → `.env`
**Bot zur Gruppe hinzufügen:**
1. Bot zu Werkstatt-Gruppe einladen
2. Chat-ID ermitteln: `https://api.telegram.org/bot<TOKEN>/getUpdates`
3. Chat-ID speichern → `.env`
**Berechtigungen:**
- ✅ Can send messages
- ✅ Can send photos (optional, für Vorschau-Bilder)
- ❌ Keine Admin-Rechte nötig
### Cron-Job für tägliche Prüfung
**Optionen:**
**A) Node-Cron (empfohlen für Development):**
```javascript
// backend/src/services/TelegramScheduler.js
const cron = require('node-cron');
// Jeden Tag um 09:00 Uhr
cron.schedule('0 9 * * *', async () => {
await checkPendingDeletions();
});
```
**B) System Cron (Production):**
```bash
# crontab -e
0 9 * * * curl -X POST http://localhost:5000/api/admin/telegram/check-deletions
```
**Neue Route:** `POST /api/admin/telegram/check-deletions` (Admin-Auth)
## Dependencies
**Neue NPM Packages:**
```json
{
"node-telegram-bot-api": "^0.66.0",
"node-cron": "^3.0.3"
}
```
## Konfiguration
### Development (.env)
```bash
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=-1001234567890
TELEGRAM_ENABLED=true
TELEGRAM_DAILY_CHECK_TIME=09:00
```
### Production
- Gleiche Variablen in `docker/prod/backend/config/.env`
- Cron-Job via Node-Cron oder System-Cron
## Sicherheit
- ✅ Bot-Token niemals committen (`.env` nur)
- ✅ Chat-ID validieren (nur bekannte Gruppen)
- ✅ Keine sensiblen Daten in Nachrichten (keine Email, keine vollständigen Token)
- ✅ Rate-Limiting für Telegram API (max 30 msg/sec)
- ✅ Error-Handling: Wenn Telegram down → Upload funktioniert trotzdem
## Testing
**Manuell:**
```bash
# Trigger Upload-Benachrichtigung
curl -X POST http://localhost:5001/api/upload-batch \
-F "images=@test.jpg" \
-F "year=2024" \
-F "title=Test Upload" \
-F "name=Test User" \
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
# Trigger Consent-Änderung
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
-H "Content-Type: application/json" \
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
# Trigger tägliche Prüfung (Admin)
curl -X POST http://localhost:5001/api/admin/telegram/check-deletions \
-b cookies.txt -H "X-CSRF-Token: $CSRF"
```
**Automatisiert:**
- Unit-Tests für `TelegramNotificationService.js`
- Mock Telegram API mit `nock`
- Prüfe Nachrichtenformat + Escaping
## Optional: Zukünftige Erweiterungen
- 📊 Wöchentlicher Statistik-Report (Uploads, Freigaben, Löschungen)
- 🖼️ Preview-Bild im Telegram (erstes Bild der Gruppe)
- 💬 Interaktive Buttons (z.B. "Freigeben", "Ablehnen") → Webhook
- 🔔 Admin-Befehle (`/stats`, `/pending`, `/cleanup`)
## Akzeptanzkriterien
- [ ] Bot sendet Nachricht bei neuem Upload
- [ ] Bot sendet Nachricht bei Consent-Änderung
- [ ] Bot sendet Nachricht bei User-Löschung
- [ ] Bot sendet tägliche Warnung für bevorstehende Löschungen (09:00 Uhr)
- [ ] Alle Nachrichten enthalten relevante Informationen + Link
- [ ] Telegram-Fehler brechen Upload/Änderungen nicht ab
- [ ] ENV-Variable `TELEGRAM_ENABLED=false` deaktiviert alle Benachrichtigungen
- [ ] README.dev.md enthält Setup-Anleitung
## Aufwandsschätzung
- Backend-Integration: ~4-6 Stunden
- Cron-Job Setup: ~2 Stunden
- Testing: ~2 Stunden
- Dokumentation: ~1 Stunde
**Gesamt: ~9-11 Stunden**
## Priorität
**Medium** - Verbessert Workflow, aber nicht kritisch für Kernfunktion
## Release-Planung
**Target Version:** `2.0.0` (Major Version)
**Begründung für Major Release:**
- Neue Infrastruktur-Abhängigkeit (Telegram Bot)
- Neue Umgebungsvariablen erforderlich
- Breaking Change: Optional, aber empfohlene Konfiguration
## Development Workflow
### 1. Feature Branch erstellen
```bash
git checkout -b feature/telegram-notifications
```
### 2. Conventional Commits verwenden
**Wichtig:** Alle Commits nach [Conventional Commits](https://www.conventionalcommits.org/) formatieren!
**Beispiele:**
```bash
git commit -m "feat: Add TelegramNotificationService"
git commit -m "feat: Add upload notification endpoint"
git commit -m "feat: Add daily deletion warning cron job"
git commit -m "chore: Add node-telegram-bot-api dependency"
git commit -m "docs: Update README with Telegram setup"
git commit -m "test: Add TelegramNotificationService unit tests"
git commit -m "fix: Handle Telegram API rate limiting"
```
**Commit-Typen:**
- `feat:` - Neue Features
- `fix:` - Bugfixes
- `docs:` - Dokumentation
- `test:` - Tests
- `chore:` - Dependencies, Config
- `refactor:` - Code-Umstrukturierung
→ **Wird automatisch im CHANGELOG.md gruppiert!**
### 3. Development Setup
**Docker Dev Environment nutzen:**
```bash
# Container starten
./dev.sh
# .env konfigurieren (Backend)
# docker/dev/backend/config/.env
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=-1001234567890
TELEGRAM_ENABLED=true
TELEGRAM_DAILY_CHECK_TIME=09:00
# Backend neu starten (lädt neue ENV-Variablen)
docker compose -f docker/dev/docker-compose.yml restart backend-dev
# Logs verfolgen
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
```
**Tests ausführen:**
```bash
cd backend
npm test -- tests/unit/TelegramNotificationService.test.js
npm test -- tests/api/telegram.test.js
```
### 4. Dokumentation aktualisieren
**README.md** - User-Dokumentation ergänzen:
- [ ] Telegram-Bot Setup-Anleitung
- [ ] Benachrichtigungs-Features beschreiben
- [ ] ENV-Variablen dokumentieren
**README.dev.md** - Development-Doku ergänzen:
- [ ] Telegram-Bot Testing-Anleitung
- [ ] Cron-Job Debugging
- [ ] TelegramNotificationService API-Referenz
- [ ] Beispiel-Curl-Commands für manuelle Trigger
**Sektion in README.dev.md einfügen (z.B. nach "Cleanup-System testen"):**
```markdown
### Telegram-Benachrichtigungen testen
```bash
# Bot-Token validieren:
curl https://api.telegram.org/bot<TOKEN>/getMe
# Chat-ID ermitteln:
curl https://api.telegram.org/bot<TOKEN>/getUpdates
# Upload-Benachrichtigung testen:
# → Einfach Upload durchführen, Telegram-Gruppe prüfen
# Consent-Änderung testen:
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
-H "Content-Type: application/json" \
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
# Tägliche Löschwarnung manuell triggern:
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST http://localhost:5001/api/admin/telegram/check-deletions
```
\`\`\`
### 5. Testing Checklist
- [ ] Unit-Tests für `TelegramNotificationService.js` (min. 80% Coverage)
- [ ] Integration-Tests für alle 3 Benachrichtigungstypen
- [ ] Manueller Test: Upload → Telegram-Nachricht kommt an
- [ ] Manueller Test: Consent-Änderung → Telegram-Nachricht kommt an
- [ ] Manueller Test: User-Löschung → Telegram-Nachricht kommt an
- [ ] Manueller Test: Cron-Job (tägliche Warnung) funktioniert
- [ ] Error-Handling: Telegram down → Upload funktioniert trotzdem
- [ ] ENV `TELEGRAM_ENABLED=false` → Keine Nachrichten
### 6. Release erstellen
**Nach erfolgreicher Implementierung:**
```bash
# Alle Änderungen committen (Conventional Commits!)
git add .
git commit -m "feat: Complete Telegram notification system"
# Feature Branch pushen
git push origin feature/telegram-notifications
# Merge in main (nach Review)
git checkout main
git merge feature/telegram-notifications
# Major Release erstellen (2.0.0)
npm run release:major
# CHANGELOG prüfen (wurde automatisch generiert!)
cat CHANGELOG.md
# Push mit Tags
git push --follow-tags
# Docker Images bauen und pushen
./prod.sh # Option 3
```
**Release Notes (automatisch in CHANGELOG.md):**
- ✨ Features: Telegram-Bot Integration (Upload, Änderungen, Lösch-Warnungen)
- 📚 Documentation: README.md + README.dev.md Updates
- 🧪 Tests: TelegramNotificationService Tests
### 7. Deployment
**Production .env updaten:**
```bash
# docker/prod/backend/config/.env
TELEGRAM_BOT_TOKEN=<production-token>
TELEGRAM_CHAT_ID=<production-chat-id>
TELEGRAM_ENABLED=true
```
**Container neu starten:**
```bash
./prod.sh # Option 4: Container neu bauen und starten
```
## Wichtige Hinweise
⚠️ **Vor dem Release prüfen:**
- README.md enthält User-Setup-Anleitung
- README.dev.md enthält Developer-Anleitung
- Alle Tests bestehen (`npm test`)
- Docker Dev Setup funktioniert
- Conventional Commits verwendet
- CHANGELOG.md ist korrekt generiert

View File

@ -302,6 +302,35 @@ describe('Example API', () => {
# 5. Log prüfen: GET http://localhost:5001/api/admin/deletion-log
```
### Telegram-Benachrichtigungen testen
**Voraussetzung:** Bot-Setup abgeschlossen (siehe `scripts/README.telegram.md`)
```bash
# 1. ENV-Variablen in docker/dev/backend/config/.env konfigurieren:
TELEGRAM_ENABLED=true
TELEGRAM_BOT_TOKEN=<dein-bot-token>
TELEGRAM_CHAT_ID=<deine-chat-id>
# 2. Backend neu starten (lädt neue ENV-Variablen):
docker compose -f docker/dev/docker-compose.yml restart backend-dev
# 3. Test-Nachricht wird automatisch beim Server-Start gesendet
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
# 4. Upload-Benachrichtigung testen (Phase 3+):
curl -X POST http://localhost:5001/api/upload-batch \
-F "images=@test.jpg" \
-F "year=2024" \
-F "title=Test Upload" \
-F "name=Test User" \
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
# → Prüfe Telegram-Gruppe auf Benachrichtigung
# 5. Service manuell deaktivieren:
TELEGRAM_ENABLED=false
```
### API-Tests
```bash
@ -593,6 +622,74 @@ Für Production mit echten Subdomains siehe:
---
## 🚀 Release Management
### Automated Release (EMPFOHLEN)
**Ein Befehl macht alles:**
```bash
npm run release # Patch: 1.2.0 → 1.2.1
npm run release:minor # Minor: 1.2.0 → 1.3.0
npm run release:major # Major: 1.2.0 → 2.0.0
```
**Was passiert automatisch:**
1. ✅ Version in allen package.json erhöht
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
4. ✅ Git Commit erstellt
5. ✅ Git Tag erstellt
6. ✅ Preview anzeigen + Bestätigung
Dann nur noch:
```bash
git push && git push --tags
```
### Beispiel-Workflow:
```bash
# Features entwickeln mit Conventional Commits:
git commit -m "feat: Add user login"
git commit -m "fix: Fix button alignment"
git commit -m "refactor: Extract ConsentFilter component"
# Release erstellen:
npm run release:minor
# Preview wird angezeigt, dann [Y] drücken
# Push:
git push && git push --tags
```
### CHANGELOG wird automatisch generiert!
Das Release-Script (`scripts/release.sh`) gruppiert deine Commits nach Typ:
- `feat:` → ✨ Features
- `fix:` → 🐛 Fixes
- `refactor:` → ♻️ Refactoring
- `chore:` → 🔧 Chores
- `docs:` → 📚 Documentation
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
### Manuelle Scripts (falls nötig)
```bash
# Version nur synchronisieren (ohne Bump):
./scripts/sync-version.sh
# Version manuell bumpen:
./scripts/bump-version.sh patch # oder minor/major
```
**Version-Synchronisation:**
- Single Source of Truth: `frontend/package.json`
- Wird synchronisiert zu: `backend/package.json`, `Footer.js`, `generate-openapi.js`, Docker Images
---
## Nützliche Befehle
```bash

155
README.md
View File

@ -5,6 +5,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## Features
**Multi-Image Upload**: Upload multiple images at once with batch processing
**Telegram Notifications**: 🆕 Real-time notifications for uploads, consent changes, deletions, and daily warnings
**Social Media Consent Management**: 🆕 GDPR-compliant consent system for workshop display and social media publishing
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
@ -20,94 +21,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## What's New
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
### 🆕 Latest Features (November 2025)
- **🌐 Public/Internal Host Separation** (Nov 25):
- Subdomain-based feature separation for production deployment
- Public host (`deinprojekt.hobbyhimmel.de`): Upload + UUID Management only
- Internal host (`deinprojekt.lan.hobbyhimmel.de`): Full admin access
- Frontend code splitting with React.lazy() for optimized bundle size
- Backend API protection via hostGate middleware
- Rate limiting: 20 uploads/hour on public host
- Audit log tracking with source host information
- Complete local testing support via /etc/hosts entries
- Zero configuration overhead for single-host deployments
- **🧪 Comprehensive Test Suite** (Nov 16):
- 45 automated tests covering all API endpoints (100% passing)
- Jest + Supertest integration testing framework
- Unit tests for authentication middleware
- API tests for admin, consent, migration, and upload endpoints
- In-memory SQLite database for isolated testing
- Coverage: 26% statements, 15% branches (realistic starting point)
- Test execution time: ~10 seconds for full suite
- CI/CD ready with proper teardown and cleanup
- **🔒 Admin Session Authentication** (Nov 16):
- Server-managed HTTP sessions for all admin/system endpoints
- CSRF protection on every mutating request via `X-CSRF-Token`
- Secure `ADMIN_SESSION_SECRET` configuration keeps cookies tamper-proof
- Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
- Session-aware moderation UI with login + first-admin setup wizard
- Complete authentication documentation in `AUTHENTICATION.md`
- **📋 API Route Documentation** (Nov 16):
- Single Source of Truth: `backend/src/routes/routeMappings.js`
- Comprehensive route overview in `backend/src/routes/README.md`
- Critical Express routing order documented (specific before generic)
- Frontend-ready route reference with authentication requirements
- OpenAPI specification auto-generation integrated
- **🔐 Social Media Consent Management** (Phase 1 Complete - Nov 9-10):
- GDPR-compliant consent system for image usage
- Mandatory workshop display consent (no upload without approval)
- Optional per-platform consents (Facebook, Instagram, TikTok)
- Consent badges and filtering in moderation panel
- CSV/JSON export for legal documentation
- Group ID tracking for consent withdrawal requests
- **🔑 Self-Service Management Portal** (Phase 2 Complete - Nov 11-15):
- Secure UUID-based management tokens for user self-service
- Frontend portal at `/manage/:token` for consent management
- Revoke/restore consents for workshop and social media
- Edit metadata (title, description) after upload
- Add/delete images after upload (with moderation re-approval)
- Complete group deletion with audit trail
- IP-based rate limiting (10 requests/hour)
- Brute-force protection (20 failed attempts → 24h ban)
- Management audit log for security tracking
- **🎨 Modular UI Architecture** (Nov 15):
- Reusable components: ConsentManager, GroupMetadataEditor, ImageDescriptionManager
- Multi-mode support: upload/edit/moderate modes for maximum reusability
- Code reduction: 62% in ModerationGroupImagesPage (281→107 lines)
- Consistent design: HTML buttons, Paper boxes, Material-UI Alerts
- Individual save/discard per component section
- Zero code duplication between pages
- **<EFBFBD> Slideshow Optimization**: Intelligent image preloading eliminates loading delays and duplicate images
- **📅 Chronological Display**: Slideshows now play in chronological order (year → upload date)
- **Automatic Cleanup**: Unapproved groups are automatically deleted after 7 days
- **Deletion Log**: Complete audit trail with statistics (groups, images, storage freed)
- **Countdown Display**: Visual indicator showing days until automatic deletion
- **Approval Feedback**: SweetAlert2 notifications for moderation actions
- **Manual Cleanup Trigger**: Admin API endpoints for testing and manual cleanup
- **Image Descriptions**: Add optional descriptions to individual images (max 200 characters)
- **Edit Mode**: Edit descriptions for uploaded images in upload preview and moderation interface
- **Slideshow Display**: Image descriptions shown as overlays during slideshow presentation
- **Public Display**: Descriptions visible in public group views and galleries
### Previous Features (October 2025)
- **Drag-and-Drop Image Reordering**: Admins can now reorder images using intuitive drag-and-drop
- **Touch-Friendly Interface**: Mobile-optimized controls with always-visible drag handles
- **Slideshow Integration**: Custom image order automatically applies to slideshow mode
- **Optimistic UI Updates**: Immediate visual feedback with error recovery
- **Comprehensive Admin Panel**: Dedicated moderation interface for content curation
### Core Features
- Multi-image batch upload with progress tracking
- Automatic slideshow presentation mode
- Image grouping with descriptions and metadata
- Random slideshow rotation with custom ordering support
- Keyboard navigation support (Slideshow: Space/Arrow keys, Escape to exit)
- Mobile-responsive design with touch-first interactions
See the [CHANGELOG](CHANGELOG.md) for a detailed list of improvements and new features.
## Quick Start
@ -262,31 +176,31 @@ The application automatically generates optimized preview thumbnails for all upl
## Docker Structure
The application uses separate Docker configurations for development and production:
The application uses separate Docker configurations for development and production with **simplified environment variable management**:
```
docker/
├── .env.backend.example # Backend environment variables documentation
├── .env.frontend.example # Frontend environment variables documentation
├── dev/ # Development environment
│ ├── docker-compose.yml # Development services configuration
│ ├── .env # 🆕 Central dev secrets (gitignored)
│ ├── .env.example # Dev environment template
│ ├── docker-compose.yml # All ENV vars defined here
│ ├── backend/
│ │ ├── config/.env # Development backend configuration
│ │ └── Dockerfile # Development backend container
│ └── frontend/
│ ├── config/.env # Development frontend configuration
│ ├── config/env.sh # Runtime configuration script
│ ├── config/env.sh # Generates window._env_ from ENV
│ ├── Dockerfile # Development frontend container
│ ├── nginx.conf # Development nginx configuration
│ └── start.sh # Development startup script
└── prod/ # Production environment
├── docker-compose.yml # Production services configuration
├── .env # 🆕 Central prod secrets (gitignored)
├── .env.example # Production environment template
├── docker-compose.yml # All ENV vars defined here
├── backend/
│ ├── config/.env # Production backend configuration
│ └── Dockerfile # Production backend container
└── frontend/
├── config/.env # Production frontend configuration
├── config/env.sh # Runtime configuration script
├── config/env.sh # Generates window._env_ from ENV
├── config/htpasswd # HTTP Basic Auth credentials
├── Dockerfile # Production frontend container
└── nginx.conf # Production nginx configuration
@ -294,6 +208,20 @@ docker/
### Environment Configuration
**🆕 Simplified ENV Structure (Nov 2025):**
- **2 central `.env` files** (down from 16 files!)
- `docker/dev/.env` - All development secrets
- `docker/prod/.env` - All production secrets
- **docker-compose.yml** - All environment variables defined in `environment:` sections
- **No .env files in Docker images** - All configuration via docker-compose
- **Frontend env.sh** - Generates `window._env_` JavaScript object from ENV variables at runtime
**How it works:**
1. Docker Compose automatically reads `.env` from the same directory
2. Variables are injected into containers via `environment:` sections using `${VAR}` placeholders
3. Frontend `env.sh` script reads ENV variables and generates JavaScript config at container startup
4. Secrets stay in gitignored `.env` files, never in code or images
- **Development**: Uses `docker/dev/` configuration with live reloading
- **Production**: Uses `docker/prod/` configuration with optimized builds
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
@ -591,12 +519,41 @@ The application includes comprehensive testing tools for the automatic cleanup f
For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
## Configuration
### Environment Variables
**Simplified ENV Management (Nov 2025):**
All environment variables are now managed through **2 central `.env` files** and `docker-compose.yml`:
**Core Variables:**
| Variable | Default | Description |
|----------|---------|-------------|
| `API_URL` | `http://localhost:5001` | Backend API endpoint |
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
| `API_URL` | `http://localhost:5001` | Backend API endpoint (frontend → backend) |
| `PUBLIC_HOST` | `public.test.local` | Public upload subdomain (no admin access) |
| `INTERNAL_HOST` | `internal.test.local` | Internal admin subdomain (full access) |
| `ADMIN_SESSION_SECRET` | - | Secret for admin session cookies (required) |
**Telegram Notifications (Optional):**
| Variable | Default | Description |
|----------|---------|-------------|
| `TELEGRAM_ENABLED` | `false` | Enable/disable Telegram notifications |
| `TELEGRAM_BOT_TOKEN` | - | Telegram Bot API token (from @BotFather) |
| `TELEGRAM_CHAT_ID` | - | Telegram chat/group ID for notifications |
| `TELEGRAM_SEND_TEST_ON_START` | `false` | Send test message on service startup (dev only) |
**Configuration Files:**
- `docker/dev/.env` - Development secrets (gitignored)
- `docker/prod/.env` - Production secrets (gitignored)
- `docker/dev/.env.example` - Development template (committed)
- `docker/prod/.env.example` - Production template (committed)
**How to configure:**
1. Copy `.env.example` to `.env` in the respective environment folder
2. Edit `.env` and set your secrets (ADMIN_SESSION_SECRET, Telegram tokens, etc.)
3. Docker Compose automatically reads `.env` and injects variables into containers
4. Never commit `.env` files (already in `.gitignore`)
**Telegram Setup:** See `scripts/README.telegram.md` for complete configuration guide.
### Volume Configuration
- **Upload Limits**: 100MB maximum file size for batch uploads

View File

@ -101,7 +101,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
[x] 🎨 Drag & Drop Reihenfolge ändern
[x] 📊 Upload-Progress mit Details
[x] 🖼️ Thumbnail-Navigation in Slideshow
[ ] 🔄 Batch-Operations (alle entfernen, etc.)
### Future Features
- 👤 User-Management

View File

@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Project Image Uploader API",
"version": "1.0.0",
"version": "2.0.1",
"description": "Auto-generated OpenAPI spec with correct mount prefixes"
},
"servers": [
@ -39,6 +39,9 @@
{
"name": "Admin - Cleanup"
},
{
"name": "Admin - Telegram"
},
{
"name": "Admin - Monitoring"
},
@ -385,6 +388,15 @@
},
"description": {
"example": "any"
},
"year": {
"example": "any"
},
"title": {
"example": "any"
},
"name": {
"example": "any"
}
}
}
@ -1058,22 +1070,38 @@
},
"/api/manage/{token}/reorder": {
"put": {
"description": "",
"tags": [
"Management Portal"
],
"summary": "Reorder images in group",
"description": "Reorder images within the managed group (token-based access)",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"type": "string"
"type": "string",
"description": "Management token (UUID v4)",
"example": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"imageIds": {
"example": "any"
"type": "array",
"example": [
1,
3,
2,
4
],
"items": {
"type": "number"
}
}
}
}
@ -1081,13 +1109,29 @@
],
"responses": {
"200": {
"description": "OK"
"description": "Images reordered successfully",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"updatedCount": {
"type": "number",
"example": 4
}
},
"xml": {
"name": "main"
}
}
},
"400": {
"description": "Bad Request"
"description": "Invalid token format or imageIds"
},
"404": {
"description": "Not Found"
"description": "Token not found or group deleted"
},
"429": {
"description": "Too Many Requests"
@ -1151,25 +1195,46 @@
},
"/api/admin/groups/{groupId}/consents": {
"post": {
"description": "",
"tags": [
"Consent Management"
],
"summary": "Save or update consents for a group",
"description": "Store workshop consent and social media consents for a specific group",
"parameters": [
{
"name": "groupId",
"in": "path",
"required": true,
"type": "string"
"type": "string",
"description": "Group ID",
"example": "abc123def456"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"workshopConsent": {
"example": "any"
"type": "boolean",
"example": true
},
"socialMediaConsents": {
"example": "any"
"type": "array",
"items": {
"type": "object",
"properties": {
"platformId": {
"type": "number",
"example": 2
},
"consented": {
"type": "boolean",
"example": false
}
}
}
}
}
}
@ -1177,10 +1242,26 @@
],
"responses": {
"200": {
"description": "OK"
"description": "Consents saved successfully",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Consents saved successfully"
}
},
"xml": {
"name": "main"
}
}
},
"400": {
"description": "Bad Request"
"description": "Invalid request data"
},
"403": {
"description": "Forbidden"
@ -1717,6 +1798,46 @@
}
}
},
"/api/admin/telegram/warning": {
"post": {
"tags": [
"Admin - Telegram"
],
"summary": "Manually trigger Telegram deletion warning",
"description": "Sends deletion warning to Telegram for testing (normally runs daily at 09:00)",
"responses": {
"200": {
"description": "Warning sent successfully",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"groupsWarned": {
"type": "number",
"example": 2
},
"message": {
"type": "string",
"example": "Deletion warning sent for 2 groups"
}
},
"xml": {
"name": "main"
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/admin/cleanup/preview": {
"get": {
"tags": [
@ -2573,6 +2694,96 @@
}
}
},
"/api/admin/groups/{groupId}/reorder": {
"put": {
"tags": [
"Admin - Groups Moderation"
],
"summary": "Reorder images in a group",
"description": "Updates the display order of images within a group",
"parameters": [
{
"name": "groupId",
"in": "path",
"required": true,
"type": "string",
"description": "Group ID",
"example": "abc123def456"
}
],
"responses": {
"200": {
"description": "Images reordered successfully",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Image order updated successfully"
},
"data": {
"type": "object",
"properties": {
"updatedImages": {
"type": "number",
"example": 5
}
}
}
},
"xml": {
"name": "main"
}
}
},
"400": {
"description": "Invalid imageIds parameter"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Group not found"
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"imageIds"
],
"properties": {
"imageIds": {
"type": "array",
"items": {
"type": "integer"
},
"example": [
5,
3,
1,
2,
4
],
"description": "Array of image IDs in new order"
}
}
}
}
}
}
}
},
"/api/admin/{groupId}/reorder": {
"put": {
"tags": [

View File

@ -26,5 +26,9 @@ module.exports = {
// Run tests serially to avoid DB conflicts
maxWorkers: 1,
// Force exit after tests complete
forceExit: true
forceExit: true,
// Transform ESM modules in node_modules
transformIgnorePatterns: [
'node_modules/(?!(uuid)/)'
]
};

View File

@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "2.0.1",
"description": "",
"main": "src/index.js",
"scripts": {
@ -31,6 +31,7 @@
"find-remove": "^2.0.3",
"fs": "^0.0.1-security",
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"sharp": "^0.34.4",
"shortid": "^2.2.16",
"sqlite3": "^5.1.7",

View File

@ -16,7 +16,7 @@ const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
const doc = {
info: {
title: 'Project Image Uploader API',
version: '1.0.0',
version: '2.0.1',
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
},
host: 'localhost:5001',

View File

@ -237,6 +237,46 @@ router.post('/cleanup/trigger', async (req, res) => {
}
});
router.post('/telegram/warning', async (req, res) => {
/*
#swagger.tags = ['Admin - Telegram']
#swagger.summary = 'Manually trigger Telegram deletion warning'
#swagger.description = 'Sends deletion warning to Telegram for testing (normally runs daily at 09:00)'
#swagger.responses[200] = {
description: 'Warning sent successfully',
schema: {
success: true,
groupsWarned: 2,
message: 'Deletion warning sent for 2 groups'
}
}
*/
try {
const schedulerService = req.app.get('schedulerService');
if (!schedulerService) {
return res.status(500).json({
success: false,
message: 'Scheduler service not available'
});
}
const result = await schedulerService.triggerTelegramWarningNow();
res.json({
success: true,
groupsWarned: result.groupCount,
message: result.message
});
} catch (error) {
console.error('[Admin API] Error triggering Telegram warning:', error);
res.status(500).json({
error: 'Internal server error',
message: error.message
});
}
});
router.get('/cleanup/preview', async (req, res) => {
/*
#swagger.tags = ['Admin - Cleanup']
@ -978,6 +1018,120 @@ router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
}
});
router.put('/groups/:groupId/reorder', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Reorder images in a group'
#swagger.description = 'Updates the display order of images within a group'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.requestBody = {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['imageIds'],
properties: {
imageIds: {
type: 'array',
items: { type: 'integer' },
example: [5, 3, 1, 2, 4],
description: 'Array of image IDs in new order'
}
}
}
}
}
}
#swagger.responses[200] = {
description: 'Images reordered successfully',
schema: {
success: true,
message: 'Image order updated successfully',
data: {
updatedImages: 5
}
}
}
#swagger.responses[400] = {
description: 'Invalid imageIds parameter'
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
const { imageIds } = req.body;
// Validate imageIds
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
return res.status(400).json({
success: false,
error: 'imageIds array is required and cannot be empty'
});
}
// Validate that all imageIds are numbers
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
if (invalidIds.length > 0) {
return res.status(400).json({
success: false,
error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
});
}
// Verify group exists
const groupData = await GroupRepository.getGroupById(groupId);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
// Execute reorder using GroupRepository
const result = await GroupRepository.updateImageOrder(groupId, imageIds);
res.status(200).json({
success: true,
message: 'Image order updated successfully',
data: result
});
} catch (error) {
console.error(`[ADMIN] Error reordering images for group ${req.params.groupId}:`, error.message);
// Handle specific errors
if (error.message.includes('not found')) {
return res.status(404).json({
success: false,
error: 'Group or images not found'
});
}
if (error.message.includes('mismatch')) {
return res.status(400).json({
success: false,
error: error.message
});
}
res.status(500).json({
success: false,
error: 'Failed to reorder images',
message: 'Fehler beim Sortieren der Bilder'
});
}
});
router.delete('/groups/:groupId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']

View File

@ -6,6 +6,10 @@ const UploadGroup = require('../models/uploadGroup');
const groupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager');
const ImagePreviewService = require('../services/ImagePreviewService');
const TelegramNotificationService = require('../services/TelegramNotificationService');
// Singleton-Instanz des Telegram Service
const telegramService = new TelegramNotificationService();
const router = Router();
@ -117,6 +121,12 @@ router.post('/upload/batch', async (req, res) => {
consents = {};
}
// Merge separate form fields into metadata (backwards compatibility)
if (req.body.year) metadata.year = parseInt(req.body.year);
if (req.body.title) metadata.title = req.body.title;
if (req.body.name) metadata.name = req.body.name;
if (req.body.description) metadata.description = req.body.description;
// Validiere Workshop Consent (Pflichtfeld)
if (!consents.workshopConsent) {
return res.status(400).json({
@ -229,6 +239,22 @@ router.post('/upload/batch', async (req, res) => {
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
telegramService.sendUploadNotification({
name: group.name,
year: group.year,
title: group.title,
imageCount: files.length,
workshopConsent: consents.workshopConsent,
socialMediaConsents: consents.socialMediaConsents || [],
token: createResult.managementToken
}).catch(err => {
// Fehler loggen, aber Upload nicht fehlschlagen lassen
console.error('[Telegram] Upload notification failed:', err.message);
});
}
// Erfolgreiche Antwort mit Management-Token
res.json({
groupId: group.groupId,

View File

@ -58,16 +58,37 @@ router.get('/social-media/platforms', async (req, res) => {
// Group Consents
// ============================================================================
/**
* POST /groups/:groupId/consents
* Speichere oder aktualisiere Consents für eine Gruppe
*
* Body: {
* workshopConsent: boolean,
* socialMediaConsents: [{ platformId: number, consented: boolean }]
* }
*/
router.post('/groups/:groupId/consents', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'Save or update consents for a group'
#swagger.description = 'Store workshop consent and social media consents for a specific group'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
workshopConsent: true,
socialMediaConsents: [
{ platformId: 1, consented: true },
{ platformId: 2, consented: false }
]
}
}
#swagger.responses[200] = {
description: 'Consents saved successfully',
schema: { success: true, message: 'Consents saved successfully' }
}
#swagger.responses[400] = {
description: 'Invalid request data'
}
*/
try {
const { groupId } = req.params;
const { workshopConsent, socialMediaConsents } = req.body;

View File

@ -5,6 +5,10 @@ const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager');
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
const auditLogMiddleware = require('../middlewares/auditLog');
const TelegramNotificationService = require('../services/TelegramNotificationService');
// Singleton-Instanz des Telegram Service
const telegramService = new TelegramNotificationService();
// Apply middleware to all management routes
router.use(rateLimitMiddleware);
@ -211,6 +215,20 @@ router.put('/:token/consents', async (req, res) => {
[newValue, groupData.groupId]
);
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
telegramService.sendConsentChangeNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
consentType: 'workshop',
action: action,
newValue: newValue === 1
}).catch(err => {
console.error('[Telegram] Consent change notification failed:', err.message);
});
}
return res.json({
success: true,
message: `Workshop consent ${action}d successfully`,
@ -263,6 +281,26 @@ router.put('/:token/consents', async (req, res) => {
}
}
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
// Hole Platform-Name für Benachrichtigung
const platform = await dbManager.get(
'SELECT platform_name FROM social_media_platforms WHERE id = ?',
[platformId]
);
telegramService.sendConsentChangeNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
consentType: 'social_media',
action: action,
platform: platform ? platform.platform_name : `Platform ${platformId}`
}).catch(err => {
console.error('[Telegram] Consent change notification failed:', err.message);
});
}
return res.json({
success: true,
message: `Social media consent ${action}d successfully`,
@ -1007,6 +1045,18 @@ router.delete('/:token', async (req, res) => {
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
// Sende Telegram-Benachrichtigung (async, non-blocking)
if (telegramService.isAvailable()) {
telegramService.sendGroupDeletedNotification({
name: groupData.name,
year: groupData.year,
title: groupData.title,
imageCount: imageCount
}).catch(err => {
console.error('[Telegram] Group deletion notification failed:', err.message);
});
}
res.json({
success: true,
message: 'Group and all associated data deleted successfully',
@ -1026,18 +1076,36 @@ router.delete('/:token', async (req, res) => {
}
});
/**
* PUT /api/manage/:token/reorder
* Reorder images within the managed group (token-based access)
*
* @param {string} token - Management token (UUID v4)
* @param {number[]} imageIds - Array of image IDs in new order
* @returns {Object} Success status and updated image count
* @throws {400} Invalid token format or imageIds
* @throws {404} Token not found or group deleted
* @throws {500} Server error
*/
router.put('/:token/reorder', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Reorder images in group'
#swagger.description = 'Reorder images within the managed group (token-based access)'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
imageIds: [1, 3, 2, 4]
}
}
#swagger.responses[200] = {
description: 'Images reordered successfully',
schema: { success: true, updatedCount: 4 }
}
#swagger.responses[400] = {
description: 'Invalid token format or imageIds'
}
#swagger.responses[404] = {
description: 'Token not found or group deleted'
}
*/
try {
const { token } = req.params;
const { imageIds } = req.body;

View File

@ -4,6 +4,10 @@ const path = require('path');
const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager');
const SchedulerService = require('./services/SchedulerService');
const TelegramNotificationService = require('./services/TelegramNotificationService');
// Singleton-Instanz des Telegram Service
const telegramService = new TelegramNotificationService();
// Dev: Swagger UI (mount only in non-production) — require lazily
let swaggerUi = null;
@ -78,8 +82,19 @@ class Server {
console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`);
// Speichere SchedulerService in app für Admin-Endpoints
this._app.set('schedulerService', SchedulerService);
// Starte Scheduler für automatisches Cleanup
SchedulerService.start();
// Teste Telegram-Service (optional, nur in Development wenn aktiviert)
if (process.env.NODE_ENV === 'development'
&& process.env.TELEGRAM_SEND_TEST_ON_START === 'true'
&& telegramService.isAvailable()) {
telegramService.sendTestMessage()
.catch(err => console.error('[Telegram] Test message failed:', err.message));
}
});
} catch (error) {
console.error('💥 Fehler beim Serverstart:', error);

View File

@ -1,9 +1,11 @@
const cron = require('node-cron');
const GroupCleanupService = require('./GroupCleanupService');
const TelegramNotificationService = require('./TelegramNotificationService');
class SchedulerService {
constructor() {
this.tasks = [];
this.telegramService = new TelegramNotificationService();
}
start() {
@ -30,7 +32,35 @@ class SchedulerService {
this.tasks.push(cleanupTask);
console.log('✓ Scheduler started - Daily cleanup at 10:00 AM (Europe/Berlin)');
// Telegram Warning-Job: Jeden Tag um 09:00 Uhr (1 Stunde vor Cleanup)
const telegramWarningTask = cron.schedule('0 9 * * *', async () => {
console.log('[Scheduler] Running daily Telegram deletion warning at 09:00 AM...');
try {
if (this.telegramService.isAvailable()) {
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
if (groupsForDeletion && groupsForDeletion.length > 0) {
await this.telegramService.sendDeletionWarning(groupsForDeletion);
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
} else {
console.log('[Scheduler] No groups pending deletion');
}
} else {
console.log('[Scheduler] Telegram service not available, skipping warning');
}
} catch (error) {
console.error('[Scheduler] Telegram warning task failed:', error);
}
}, {
scheduled: true,
timezone: "Europe/Berlin"
});
this.tasks.push(telegramWarningTask);
console.log('✓ Scheduler started:');
console.log(' - Daily cleanup at 10:00 AM (Europe/Berlin)');
console.log(' - Daily Telegram warning at 09:00 AM (Europe/Berlin)');
// Für Development: Manueller Trigger
if (process.env.NODE_ENV === 'development') {
@ -50,6 +80,42 @@ class SchedulerService {
console.log('[Scheduler] Manual cleanup triggered...');
return await GroupCleanupService.performScheduledCleanup();
}
// Für Development: Manueller Telegram-Warning-Trigger
async triggerTelegramWarningNow() {
console.log('[Scheduler] Manual Telegram warning triggered...');
try {
if (!this.telegramService.isAvailable()) {
console.log('[Scheduler] Telegram service not available');
return { success: false, message: 'Telegram service not available' };
}
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
if (!groupsForDeletion || groupsForDeletion.length === 0) {
console.log('[Scheduler] No groups pending deletion');
return { success: true, message: 'No groups pending deletion', groupCount: 0 };
}
await this.telegramService.sendDeletionWarning(groupsForDeletion);
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
return {
success: true,
message: `Warning sent for ${groupsForDeletion.length} groups`,
groupCount: groupsForDeletion.length,
groups: groupsForDeletion.map(g => ({
groupId: g.groupId,
name: g.name,
year: g.year,
uploadDate: g.uploadDate
}))
};
} catch (error) {
console.error('[Scheduler] Manual Telegram warning failed:', error);
return { success: false, message: error.message };
}
}
}
module.exports = new SchedulerService();

View File

@ -0,0 +1,312 @@
const TelegramBot = require('node-telegram-bot-api');
/**
* TelegramNotificationService
*
* Versendet automatische Benachrichtigungen über Telegram an die Werkstatt-Gruppe.
*
* Features:
* - Upload-Benachrichtigungen (Phase 3)
* - Consent-Änderungs-Benachrichtigungen (Phase 4)
* - Gruppen-Lösch-Benachrichtigungen (Phase 4)
* - Tägliche Lösch-Warnungen (Phase 5)
*
* Phase 2: Backend-Service Integration (Basic Setup)
*/
class TelegramNotificationService {
constructor() {
this.enabled = process.env.TELEGRAM_ENABLED === 'true';
this.botToken = process.env.TELEGRAM_BOT_TOKEN;
this.chatId = process.env.TELEGRAM_CHAT_ID;
this.bot = null;
if (this.enabled) {
this.initialize();
} else {
console.log('[Telegram] Service disabled (TELEGRAM_ENABLED=false)');
}
}
/**
* Initialisiert den Telegram Bot
*/
initialize() {
try {
if (!this.botToken) {
throw new Error('TELEGRAM_BOT_TOKEN is not defined');
}
if (!this.chatId) {
throw new Error('TELEGRAM_CHAT_ID is not defined');
}
this.bot = new TelegramBot(this.botToken, { polling: false });
console.log('[Telegram] Service initialized successfully');
} catch (error) {
console.error('[Telegram] Initialization failed:', error.message);
this.enabled = false;
}
}
/**
* Prüft, ob der Service verfügbar ist
*/
isAvailable() {
return this.enabled && this.bot !== null;
}
/**
* Sendet eine Test-Nachricht
*
* @returns {Promise<Object>} Telegram API Response
*/
async sendTestMessage() {
if (!this.isAvailable()) {
console.log('[Telegram] Service not available, skipping test message');
return null;
}
try {
const timestamp = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const message = `
🤖 Telegram Service Test
Service erfolgreich initialisiert!
Zeitstempel: ${timestamp}
Environment: ${process.env.NODE_ENV || 'development'}
---
Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
`.trim();
const response = await this.bot.sendMessage(this.chatId, message);
console.log('[Telegram] Test message sent successfully');
return response;
} catch (error) {
console.error('[Telegram] Failed to send test message:', error.message);
throw error;
}
}
/**
* Phase 3: Sendet Benachrichtigung bei neuem Upload
*
* @param {Object} groupData - Gruppen-Informationen
* @param {string} groupData.name - Name des Uploaders
* @param {number} groupData.year - Jahr der Gruppe
* @param {string} groupData.title - Titel der Gruppe
* @param {number} groupData.imageCount - Anzahl hochgeladener Bilder
* @param {boolean} groupData.workshopConsent - Workshop-Consent Status
* @param {Array<string>} groupData.socialMediaConsents - Social Media Plattformen
* @param {string} groupData.token - Management-Token
*/
async sendUploadNotification(groupData) {
if (!this.isAvailable()) {
console.log('[Telegram] Service not available, skipping upload notification');
return null;
}
try {
const workshopIcon = groupData.workshopConsent ? '✅' : '❌';
const socialMediaIcons = this.formatSocialMediaIcons(groupData.socialMediaConsents);
const message = `
📸 Neuer Upload!
Uploader: ${groupData.name}
Bilder: ${groupData.imageCount}
Gruppe: ${groupData.year} - ${groupData.title}
Workshop: ${workshopIcon} ${groupData.workshopConsent ? 'Ja' : 'Nein'}
Social Media: ${socialMediaIcons || '❌ Keine'}
🔗 Zur Freigabe: ${this.getAdminUrl()}
`.trim();
const response = await this.bot.sendMessage(this.chatId, message);
console.log(`[Telegram] Upload notification sent for group: ${groupData.title}`);
return response;
} catch (error) {
console.error('[Telegram] Failed to send upload notification:', error.message);
// Fehler loggen, aber nicht werfen - Upload soll nicht fehlschlagen wegen Telegram
return null;
}
}
/**
* Phase 4: Sendet Benachrichtigung bei Consent-Änderung
*
* @param {Object} changeData - Änderungs-Informationen
* @param {string} changeData.name - Name des Uploaders
* @param {number} changeData.year - Jahr
* @param {string} changeData.title - Titel
* @param {string} changeData.consentType - 'workshop' oder 'social_media'
* @param {string} changeData.action - 'revoke' oder 'restore'
* @param {string} [changeData.platform] - Plattform-Name (nur bei social_media)
* @param {boolean} [changeData.newValue] - Neuer Wert (nur bei workshop)
*/
async sendConsentChangeNotification(changeData) {
if (!this.isAvailable()) {
console.log('[Telegram] Service not available, skipping consent change notification');
return null;
}
try {
const { name, year, title, consentType, action, platform, newValue } = changeData;
let changeDescription;
if (consentType === 'workshop') {
const icon = newValue ? '✅' : '❌';
const status = newValue ? 'Ja' : 'Nein';
const actionText = action === 'revoke' ? 'widerrufen' : 'wiederhergestellt';
changeDescription = `Workshop-Consent ${actionText}\nNeuer Status: ${icon} ${status}`;
} else if (consentType === 'social_media') {
const actionText = action === 'revoke' ? 'widerrufen' : 'erteilt';
changeDescription = `Social Media Consent ${actionText}\nPlattform: ${platform}`;
}
const message = `
Consent-Änderung
Gruppe: ${year} - ${title}
Uploader: ${name}
${changeDescription}
🔗 Details: ${this.getAdminUrl()}
`.trim();
const response = await this.bot.sendMessage(this.chatId, message);
console.log(`[Telegram] Consent change notification sent for: ${title}`);
return response;
} catch (error) {
console.error('[Telegram] Failed to send consent change notification:', error.message);
throw error;
}
}
/**
* Phase 4: Sendet Benachrichtigung bei Gruppen-Löschung durch User
*
* @param {Object} groupData - Gruppen-Informationen
*/
async sendGroupDeletedNotification(groupData) {
if (!this.isAvailable()) {
console.log('[Telegram] Service not available, skipping group deleted notification');
return null;
}
try {
const message = `
User-Änderung
Aktion: Gruppe gelöscht
Gruppe: ${groupData.year} - ${groupData.title}
Uploader: ${groupData.name}
Bilder: ${groupData.imageCount}
User hat Gruppe selbst über Management-Link gelöscht
`.trim();
const response = await this.bot.sendMessage(this.chatId, message);
console.log(`[Telegram] Group deleted notification sent for: ${groupData.title}`);
return response;
} catch (error) {
console.error('[Telegram] Failed to send group deleted notification:', error.message);
return null;
}
}
/**
* Phase 5: Sendet tägliche Warnung für bevorstehende Löschungen
*
* @param {Array<Object>} groupsList - Liste der zu löschenden Gruppen
*/
async sendDeletionWarning(groupsList) {
if (!this.isAvailable()) {
console.log('[Telegram] Service not available, skipping deletion warning');
return null;
}
if (!groupsList || groupsList.length === 0) {
console.log('[Telegram] No groups pending deletion, skipping warning');
return null;
}
try {
let groupsText = groupsList.map((group, index) => {
const uploadDate = new Date(group.created_at).toLocaleDateString('de-DE');
return `${index + 1}. ${group.year} - ${group.title}
Uploader: ${group.name}
Bilder: ${group.imageCount}
Hochgeladen: ${uploadDate}`;
}).join('\n\n');
const message = `
Löschung in 24 Stunden!
Folgende Gruppen werden morgen automatisch gelöscht:
${groupsText}
💡 Jetzt freigeben oder Freigabe bleibt aus!
🔗 Zur Moderation: ${this.getAdminUrl()}
`.trim();
const response = await this.bot.sendMessage(this.chatId, message);
console.log(`[Telegram] Deletion warning sent for ${groupsList.length} groups`);
return response;
} catch (error) {
console.error('[Telegram] Failed to send deletion warning:', error.message);
return null;
}
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Formatiert Social Media Consents als Icons
*
* @param {Array<string>} platforms - Liste der Plattformen
* @returns {string} Formatierter String mit Icons
*/
formatSocialMediaIcons(platforms) {
if (!platforms || platforms.length === 0) {
return '';
}
const iconMap = {
'facebook': '📘 Facebook',
'instagram': '📷 Instagram',
'tiktok': '🎵 TikTok'
};
return platforms.map(p => iconMap[p.toLowerCase()] || p).join(', ');
}
/**
* Gibt die Admin-URL zurück
*
* @returns {string} Admin-Panel URL
*/
getAdminUrl() {
const host = process.env.INTERNAL_HOST || 'internal.hobbyhimmel.de';
const isProduction = process.env.NODE_ENV === 'production';
const protocol = isProduction ? 'https' : 'http';
const port = isProduction ? '' : ':3000';
return `${protocol}://${host}${port}/moderation`;
}
}
module.exports = TelegramNotificationService;

View File

@ -0,0 +1,183 @@
/**
* Integration Tests für Telegram Upload-Benachrichtigungen
*
* Phase 3: Upload-Benachrichtigungen
*
* Diese Tests prüfen die Integration zwischen Upload-Route und Telegram-Service
*/
const path = require('path');
const fs = require('fs');
const { getRequest } = require('../testServer');
describe('Telegram Upload Notifications (Integration)', () => {
let TelegramNotificationService;
let sendUploadNotificationSpy;
beforeAll(() => {
// Spy auf TelegramNotificationService
TelegramNotificationService = require('../../src/services/TelegramNotificationService');
});
beforeEach(() => {
// Spy auf sendUploadNotification erstellen
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification')
.mockResolvedValue({ message_id: 42 });
// isAvailable() immer true zurückgeben für Tests
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
.mockReturnValue(true);
});
afterEach(() => {
// Restore alle Spys
jest.restoreAllMocks();
});
describe('POST /api/upload/batch', () => {
const testImagePath = path.join(__dirname, '../utils/test-image.jpg');
// Erstelle Test-Bild falls nicht vorhanden
beforeAll(() => {
if (!fs.existsSync(testImagePath)) {
// Erstelle 1x1 px JPEG
const buffer = Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x03, 0xFF, 0xC4, 0x00, 0x14,
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9
]);
fs.writeFileSync(testImagePath, buffer);
}
});
it('sollte Telegram-Benachrichtigung bei erfolgreichem Upload senden', async () => {
const response = await getRequest()
.post('/api/upload/batch')
.field('year', '2024')
.field('title', 'Test Upload')
.field('name', 'Test User')
.field('consents', JSON.stringify({
workshopConsent: true,
socialMediaConsents: ['instagram', 'tiktok']
}))
.attach('images', testImagePath);
// Upload sollte erfolgreich sein
expect(response.status).toBe(200);
expect(response.body.message).toBe('Batch upload successful');
// Warte kurz auf async Telegram-Call
await new Promise(resolve => setTimeout(resolve, 150));
// Telegram-Service sollte aufgerufen worden sein
expect(sendUploadNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test User',
year: 2024,
title: 'Test Upload',
imageCount: 1,
workshopConsent: true,
socialMediaConsents: ['instagram', 'tiktok']
})
);
});
it('sollte Upload nicht fehlschlagen wenn Telegram-Service nicht verfügbar', async () => {
// Restore mock und setze isAvailable auf false
jest.restoreAllMocks();
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
.mockReturnValue(false);
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification');
const response = await getRequest()
.post('/api/upload/batch')
.field('year', '2024')
.field('title', 'Test Upload')
.field('name', 'Test User')
.field('consents', JSON.stringify({
workshopConsent: false,
socialMediaConsents: []
}))
.attach('images', testImagePath);
// Upload sollte trotzdem erfolgreich sein
expect(response.status).toBe(200);
expect(response.body.message).toBe('Batch upload successful');
// Telegram sollte nicht aufgerufen worden sein
expect(sendUploadNotificationSpy).not.toHaveBeenCalled();
});
it('sollte Upload nicht fehlschlagen wenn Telegram-Benachrichtigung fehlschlägt', async () => {
sendUploadNotificationSpy.mockRejectedValueOnce(
new Error('Telegram API Error')
);
const response = await getRequest()
.post('/api/upload/batch')
.field('year', '2024')
.field('title', 'Test Upload')
.field('name', 'Test User')
.field('consents', JSON.stringify({
workshopConsent: true,
socialMediaConsents: []
}))
.attach('images', testImagePath);
// Upload sollte trotzdem erfolgreich sein
expect(response.status).toBe(200);
expect(response.body.message).toBe('Batch upload successful');
// Warte auf async error handling
await new Promise(resolve => setTimeout(resolve, 150));
// Telegram wurde versucht aufzurufen
expect(sendUploadNotificationSpy).toHaveBeenCalled();
});
it('sollte korrekte Daten an Telegram-Service übergeben', async () => {
const response = await getRequest()
.post('/api/upload/batch')
.field('year', '2025')
.field('title', 'Schweißkurs November')
.field('name', 'Max Mustermann')
.field('consents', JSON.stringify({
workshopConsent: true,
socialMediaConsents: ['facebook', 'instagram']
}))
.attach('images', testImagePath)
.attach('images', testImagePath);
expect(response.status).toBe(200);
await new Promise(resolve => setTimeout(resolve, 150));
expect(sendUploadNotificationSpy).toHaveBeenCalledWith({
name: 'Max Mustermann',
year: 2025,
title: 'Schweißkurs November',
imageCount: 2,
workshopConsent: true,
socialMediaConsents: ['facebook', 'instagram'],
token: expect.any(String)
});
});
});
});

View File

@ -0,0 +1,216 @@
/**
* Unit Tests für TelegramNotificationService
*
* Phase 2: Basic Service Tests
*/
const TelegramNotificationService = require('../../src/services/TelegramNotificationService');
// Mock node-telegram-bot-api komplett
jest.mock('node-telegram-bot-api');
describe('TelegramNotificationService', () => {
let originalEnv;
let TelegramBot;
let mockBotInstance;
beforeAll(() => {
TelegramBot = require('node-telegram-bot-api');
});
beforeEach(() => {
// Speichere originale ENV-Variablen
originalEnv = { ...process.env };
// Setze Test-ENV
process.env.TELEGRAM_ENABLED = 'true';
process.env.TELEGRAM_BOT_TOKEN = 'test-bot-token-123';
process.env.TELEGRAM_CHAT_ID = '-1001234567890';
// Erstelle Mock-Bot-Instanz
mockBotInstance = {
sendMessage: jest.fn().mockResolvedValue({
message_id: 42,
chat: { id: -1001234567890 },
text: 'Test'
}),
getMe: jest.fn().mockResolvedValue({
id: 123456,
first_name: 'Test Bot',
username: 'test_bot'
})
};
// Mock TelegramBot constructor
TelegramBot.mockImplementation(() => mockBotInstance);
});
afterEach(() => {
// Restore original ENV
process.env = originalEnv;
});
describe('Initialization', () => {
it('sollte erfolgreich initialisieren wenn TELEGRAM_ENABLED=true', () => {
const service = new TelegramNotificationService();
expect(service.isAvailable()).toBe(true);
expect(TelegramBot).toHaveBeenCalledWith('test-bot-token-123', { polling: false });
});
it('sollte nicht initialisieren wenn TELEGRAM_ENABLED=false', () => {
process.env.TELEGRAM_ENABLED = 'false';
const service = new TelegramNotificationService();
expect(service.isAvailable()).toBe(false);
});
it('sollte fehlschlagen wenn TELEGRAM_BOT_TOKEN fehlt', () => {
delete process.env.TELEGRAM_BOT_TOKEN;
const service = new TelegramNotificationService();
expect(service.isAvailable()).toBe(false);
});
it('sollte fehlschlagen wenn TELEGRAM_CHAT_ID fehlt', () => {
delete process.env.TELEGRAM_CHAT_ID;
const service = new TelegramNotificationService();
expect(service.isAvailable()).toBe(false);
});
});
describe('sendTestMessage', () => {
it('sollte Test-Nachricht erfolgreich senden', async () => {
const service = new TelegramNotificationService();
const result = await service.sendTestMessage();
expect(result).toBeDefined();
expect(result.message_id).toBe(42);
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
expect.stringContaining('Telegram Service Test')
);
});
it('sollte null zurückgeben wenn Service nicht verfügbar', async () => {
process.env.TELEGRAM_ENABLED = 'false';
const service = new TelegramNotificationService();
const result = await service.sendTestMessage();
expect(result).toBeNull();
});
it('sollte Fehler werfen bei Telegram-API-Fehler', async () => {
const service = new TelegramNotificationService();
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('API Error'));
await expect(service.sendTestMessage()).rejects.toThrow('API Error');
});
});
describe('formatSocialMediaIcons', () => {
it('sollte Social Media Plattformen korrekt formatieren', () => {
const service = new TelegramNotificationService();
const result = service.formatSocialMediaIcons(['facebook', 'instagram', 'tiktok']);
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
});
it('sollte leeren String bei leerer Liste zurückgeben', () => {
const service = new TelegramNotificationService();
const result = service.formatSocialMediaIcons([]);
expect(result).toBe('');
});
it('sollte case-insensitive sein', () => {
const service = new TelegramNotificationService();
const result = service.formatSocialMediaIcons(['FACEBOOK', 'Instagram', 'TikTok']);
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
});
});
describe('getAdminUrl', () => {
it('sollte Admin-URL mit PUBLIC_URL generieren', () => {
process.env.PUBLIC_URL = 'https://test.example.com';
const service = new TelegramNotificationService();
const url = service.getAdminUrl();
expect(url).toBe('https://test.example.com/moderation');
});
it('sollte Default-URL verwenden wenn PUBLIC_URL nicht gesetzt', () => {
delete process.env.PUBLIC_URL;
const service = new TelegramNotificationService();
const url = service.getAdminUrl();
expect(url).toBe('https://internal.hobbyhimmel.de/moderation');
});
});
describe('sendUploadNotification (Phase 3)', () => {
it('sollte Upload-Benachrichtigung mit korrekten Daten senden', async () => {
const service = new TelegramNotificationService();
const groupData = {
name: 'Max Mustermann',
year: 2024,
title: 'Schweißkurs November',
imageCount: 12,
workshopConsent: true,
socialMediaConsents: ['instagram', 'tiktok'],
token: 'test-token-123'
};
await service.sendUploadNotification(groupData);
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
expect.stringContaining('📸 Neuer Upload!')
);
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
expect.stringContaining('Max Mustermann')
);
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
expect.stringContaining('Bilder: 12')
);
});
it('sollte null zurückgeben und nicht werfen bei Fehler', async () => {
const service = new TelegramNotificationService();
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('Network error'));
const groupData = {
name: 'Test User',
year: 2024,
title: 'Test',
imageCount: 5,
workshopConsent: false,
socialMediaConsents: []
};
const result = await service.sendUploadNotification(groupData);
expect(result).toBeNull();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

View File

@ -10,6 +10,22 @@ NODE_ENV=development
# Port for the backend server
PORT=5000
# Admin Session Secret (IMPORTANT: Change in production!)
# Generate with: openssl rand -base64 32
ADMIN_SESSION_SECRET=change-me-in-production
# Telegram Bot Configuration (optional)
TELEGRAM_ENABLED=false
# Send test message on server start (development only)
TELEGRAM_SEND_TEST_ON_START=false
# Bot-Token from @BotFather
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
TELEGRAM_BOT_TOKEN=your-bot-token-here
# Chat-ID of the Telegram group (negative for groups!)
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
# Example: -1001234567890
TELEGRAM_CHAT_ID=your-chat-id-here
# Database settings (if needed in future)
# DB_HOST=localhost
# DB_PORT=3306

View File

@ -6,7 +6,4 @@
# Production: http://backend:5000 (container-to-container)
API_URL=http://backend:5000
# Client URL - the URL where users access the frontend
# Development: http://localhost:3000 (dev server)
# Production: http://localhost (nginx on port 80)
CLIENT_URL=http://localhost
# Public/Internal host separation (optional)

16
docker/dev/.env.example Normal file
View File

@ -0,0 +1,16 @@
# Docker Compose Environment Variables for Development
# Copy this file to .env and adjust values
# Admin Session Secret (optional, has default: dev-session-secret-change-me)
#ADMIN_SESSION_SECRET=your-secret-here
# Telegram Bot Configuration (optional)
TELEGRAM_ENABLED=false
TELEGRAM_SEND_TEST_ON_START=false
# Bot-Token from @BotFather
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
TELEGRAM_BOT_TOKEN=your-bot-token-here
# Chat-ID of the Telegram group (negative for groups!)
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
# Example: -1001234567890
TELEGRAM_CHAT_ID=your-chat-id-here

View File

@ -12,8 +12,8 @@ RUN npm install
# Copy backend source code
COPY backend/ .
# Copy development environment configuration
COPY docker/dev/backend/config/.env ./.env
# Note: Environment variables are set via docker-compose.yml
# No .env file needed in the image
# Expose port
EXPOSE 5000

View File

@ -15,11 +15,9 @@ services:
volumes:
- ../../frontend:/app:cached
- dev_frontend_node_modules:/app/node_modules
- ./frontend/config/.env:/app/.env:ro
environment:
- CHOKIDAR_USEPOLLING=true
- API_URL=http://localhost:5001
- CLIENT_URL=http://localhost:3000
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
depends_on:
@ -39,14 +37,20 @@ services:
volumes:
- ../../backend:/usr/src/app:cached
- dev_backend_node_modules:/usr/src/app/node_modules
- ./backend/config/.env:/usr/src/app/.env:ro
environment:
- NODE_ENV=development
- PORT=5000
- REMOVE_IMAGES=false
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-dev-session-secret-change-me}
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true
- TRUST_PROXY_HOPS=0
- PUBLIC_UPLOAD_RATE_LIMIT=20
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
- TELEGRAM_SEND_TEST_ON_START=${TELEGRAM_SEND_TEST_ON_START:-false}
networks:
- dev-internal
command: [ "npm", "run", "server" ]

View File

@ -13,9 +13,9 @@ WORKDIR /app
# Copy package files first to leverage Docker cache for npm install
COPY frontend/package*.json ./
# Copy environment configuration
# Copy environment shell script (generates env-config.js from ENV at runtime)
COPY docker/dev/frontend/config/env.sh ./env.sh
COPY docker/dev/frontend/config/.env ./.env
# Note: ENV variables are set via docker-compose.yml, not from .env file
# Make env.sh executable
RUN chmod +x ./env.sh

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
while read -r line || [[ -n "$line" ]];
do
# Split env variables by character `=`
if printf '%s\n' "$line" | grep -q -e '='; then
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
fi
# List of environment variables to export (add more as needed)
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Read each environment variable and add to config
for varname in $ENV_VARS; do
# Get value from environment using indirect expansion
value="${!varname}"
# Append configuration property to JS file
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
fi
done
echo "}" >> ./env-config.js

18
docker/prod/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Docker Compose Environment Variables for Production
# Copy this file to .env and adjust values
# IMPORTANT: Keep this file secure and never commit .env to git!
# Admin Session Secret (REQUIRED: Generate new secret!)
# Generate with: openssl rand -base64 32
ADMIN_SESSION_SECRET=CHANGE-ME-IN-PRODUCTION
# Telegram Bot Configuration (optional)
# Set to true to enable Telegram notifications in production
TELEGRAM_ENABLED=false
# Bot-Token from @BotFather (production bot)
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
TELEGRAM_BOT_TOKEN=your-production-bot-token-here
# Chat-ID of the production Telegram group (negative for groups!)
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
# Example: -1001234567890
TELEGRAM_CHAT_ID=your-production-chat-id-here

View File

@ -15,9 +15,8 @@ services:
- backend
environment:
- API_URL=http://backend:5000
- CLIENT_URL=http://localhost
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
networks:
- npm-nw
@ -43,13 +42,18 @@ services:
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
- ADMIN_SESSION_COOKIE_SECURE=true
# Host Configuration (Public/Internal Separation)
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true
- PUBLIC_UPLOAD_RATE_LIMIT=20
- PUBLIC_UPLOAD_RATE_WINDOW=3600000
# Trust nginx-proxy-manager (1 hop)
- TRUST_PROXY_HOPS=1
# Telegram Bot Configuration (optional)
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
- TELEGRAM_SEND_TEST_ON_START=false

View File

@ -20,10 +20,10 @@ COPY --from=build /app/build /usr/share/nginx/html
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
# Copy .env shell script to container (generates env-config.js from ENV at runtime)
WORKDIR /usr/share/nginx/html
COPY docker/prod/frontend/config/env.sh ./env.sh
COPY docker/prod/frontend/config/.env ./.env
# Note: ENV variables are set via docker-compose.yml, not from .env file
# Add bash
RUN apk add --no-cache bash

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
while read -r line || [[ -n "$line" ]];
do
# Split env variables by character `=`
if printf '%s\n' "$line" | grep -q -e '='; then
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
fi
# List of environment variables to export (add more as needed)
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Read each environment variable and add to config
for varname in $ENV_VARS; do
# Get value from environment using indirect expansion
value="${!varname}"
# Append configuration property to JS file
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
fi
done
echo "}" >> ./env-config.js

149
frontend/ERROR_HANDLING.md Normal file
View File

@ -0,0 +1,149 @@
# Error Handling System
Das Frontend verfügt jetzt über ein vollständiges Error Handling System für HTTP-Fehler und React-Fehler.
## ✅ Migration abgeschlossen
Alle kritischen API-Aufrufe wurden auf das neue Error-Handling-System migriert:
- ✅ `sendRequest.js``apiClient` (axios-basiert)
- ✅ `batchUpload.js``apiFetch`
- ✅ `PublicGroupImagesPage.js``apiFetch`
- ✅ `ManagementPortalPage.js``apiFetch`
- ✅ `DeleteGroupButton.js``apiFetch`
- ✅ `ConsentManager.js``apiFetch`
- ✅ `ImageDescriptionManager.js``apiFetch`
- ✅ `GroupMetadataEditor.js``apiFetch`
**Hinweis:** `adminApi.js` und `socialMediaApi.js` verwenden ihr eigenes `adminFetch`-System mit CSRF-Token-Handling und wurden bewusst nicht migriert.
## Komponenten
### 1. ErrorBoundary (`/Components/ComponentUtils/ErrorBoundary.js`)
- Fängt React-Fehler (z.B. Rendering-Fehler) ab
- Zeigt automatisch die 500-Error-Page bei unerwarteten Fehlern
- Loggt Fehlerdetails in der Konsole für Debugging
### 2. API Client (`/Utils/apiClient.js`)
- Axios-Instance mit Response-Interceptor
- Für FormData-Uploads (z.B. Bilder)
- Automatische Weiterleitung zu Error-Pages basierend auf HTTP-Statuscode
### 3. API Fetch Wrapper (`/Utils/apiFetch.js`)
- Native Fetch-Wrapper mit Error-Handling
- Für Standard-JSON-API-Aufrufe
- Automatische Weiterleitung zu Error-Pages:
- **403 Forbidden**`/error/403`
- **500 Internal Server Error**`/error/500`
- **502 Bad Gateway**`/error/502`
- **503 Service Unavailable**`/error/503`
### 4. Error Pages Routes (`App.js`)
- Neue Routes für alle Error-Pages:
- `/error/403` - Forbidden
- `/error/500` - Internal Server Error
- `/error/502` - Bad Gateway
- `/error/503` - Service Unavailable
- `*` - 404 Not Found (catch-all)
## Verwendung
### Für File-Uploads (FormData)
Verwende `apiClient` für multipart/form-data Uploads:
```javascript
import apiClient from '../Utils/apiClient';
const formData = new FormData();
formData.append('file', file);
apiClient.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.then(response => {
// Success handling
})
.catch(error => {
// Automatische Weiterleitung zu Error-Page bei 403, 500, 502, 503
});
```
### Für JSON-API-Aufrufe
Verwende `apiFetch` oder Helper-Funktionen:
```javascript
import { apiFetch, apiGet, apiPost } from '../Utils/apiFetch';
// GET Request
const data = await apiGet('/api/groups');
// POST Request
const result = await apiPost('/api/groups', { name: 'Test' });
// Custom Request
const response = await apiFetch('/api/groups/123', {
method: 'DELETE'
});
```
## Backend Error Codes
Das Backend liefert bereits folgende Statuscodes:
- **403**: CSRF-Fehler, fehlende Admin-Session, public host auf internal routes
- **500**: Datenbank-Fehler, Upload-Fehler, Migration-Fehler
- **502**: Nicht implementiert (wird von Reverse Proxy geliefert)
- **503**: Nicht implementiert (für Wartungsmodus vorgesehen)
## Testing
Um die Error-Pages zu testen:
1. **403**: Versuche ohne Login auf Admin-Routen zuzugreifen
2. **404**: Navigiere zu einer nicht existierenden Route (z.B. `/nicht-vorhanden`)
3. **500**: Simuliere Backend-Fehler
4. **502/503**: Manuell über `/error/502` oder `/error/503` aufrufen
## Architektur
```
┌─────────────────────────────────────────────┐
│ App.js │
│ ┌───────────────────────────────────────┐ │
│ │ ErrorBoundary │ │
│ │ (fängt React-Fehler) │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Router │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Routes │ │ │ │
│ │ │ │ - / │ │ │ │
│ │ │ │ - /error/403 │ │ │ │
│ │ │ │ - /error/500 │ │ │ │
│ │ │ │ - /error/502 │ │ │ │
│ │ │ │ - /error/503 │ │ │ │
│ │ │ │ - * (404) │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ API Layer │
├─────────────────────────────────────────────┤
│ apiClient.js (axios) │
│ - FormData/File-Uploads │
│ - Response Interceptor │
│ │
│ apiFetch.js (fetch) │
│ - JSON-API-Aufrufe │
│ - Error-Response-Handling │
│ │
│ adminApi.js (fetch + CSRF) │
│ - Admin-Authentifizierung │
│ - CSRF-Token-Management │
│ - Nicht migriert (eigenes System) │
└─────────────────────────────────────────────┘
Error-Flow:
HTTP 403/500/502/503 → Interceptor/Handler → window.location.href → Error-Page
React Error → ErrorBoundary → 500-Page
```

View File

@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.1.0",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.1.0",
"version": "2.0.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "1.1.0",
"version": "2.0.1",
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -31,7 +31,8 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"version": "cd .. && ./scripts/sync-version.sh && git add -A"
},
"proxy": "http://backend-dev:5000",
"eslintConfig": {

View File

@ -18,8 +18,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">

View File

@ -1,15 +1,205 @@
/* Main shared styles for cards, buttons, modals used across pages */
/* ============================================
TYPOGRAPHY - Zentrale Schrift-Definitionen
============================================ */
body {
font-family: 'Open Sans', sans-serif;
color: #333333;
line-height: 1.6;
}
h1, .h1 {
font-family: 'Open Sans', sans-serif;
font-weight: 500;
font-size: 28px;
color: #333333;
margin-bottom: 10px;
}
h2, .h2 {
font-family: 'Open Sans', sans-serif;
font-weight: 500;
font-size: 24px;
color: #333333;
margin-bottom: 15px;
}
h3, .h3 {
font-family: 'Open Sans', sans-serif;
font-weight: 500;
font-size: 20px;
color: #333333;
margin-bottom: 12px;
}
p, .text-body {
font-family: 'Open Sans', sans-serif;
font-weight: 400;
font-size: 16px;
color: #666666;
margin-bottom: 16px;
}
.text-subtitle {
font-family: 'Open Sans', sans-serif;
font-weight: 300;
font-size: 16px;
color: #666666;
}
.text-small {
font-size: 14px;
color: #666666;
}
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
/* ============================================
LAYOUT & CONTAINERS
============================================ */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 20px;
margin-bottom: 20px;
}
.card-content {
padding: 20px;
}
/* ============================================
PAGE HEADERS
============================================ */
.page-header {
text-align: center;
margin-bottom: 30px;
}
.page-title {
font-family: 'Open Sans', sans-serif;
font-weight: 500;
font-size: 28px;
color: #333333;
text-align: center;
margin-bottom: 10px;
}
.page-subtitle {
font-family: 'Open Sans', sans-serif;
font-weight: 300;
font-size: 16px;
color: #666666;
text-align: center;
margin-bottom: 30px;
}
/* ============================================
UTILITY CLASSES
============================================ */
.flex-center {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
margin-top: 30px;
}
.text-center-block {
text-align: center;
padding: 40px 0;
}
/* Spacing utilities */
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mt-3 { margin-top: 24px; }
.mt-4 { margin-top: 32px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.mb-3 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 32px; }
.p-2 { padding: 16px; }
.p-3 { padding: 24px; }
/* ============================================
SUCCESS BOX (Upload Success)
============================================ */
.success-box {
margin-top: 32px;
padding: 24px;
border-radius: 12px;
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
color: white;
box-shadow: 0 4px 20px rgba(76, 175, 80, 0.4);
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-box h2 {
font-size: 28px;
font-weight: bold;
margin-bottom: 8px;
color: white;
}
.success-box p {
font-size: 18px;
margin-bottom: 16px;
color: white;
}
.info-box {
background: rgba(255,255,255,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.info-box-highlight {
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
border: 2px solid rgba(255,255,255,0.3);
}
/* ============================================
EXISTING STYLES BELOW
============================================ */
/* Page-specific styles for GroupsOverviewPage */
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
.header-title { font-family: roboto; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
.header-subtitle { font-family: roboto; font-size: 16px; color: #666666; margin-bottom: 20px; }
.header-title { font-family: 'Open Sans', sans-serif; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
.header-subtitle { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #666666; margin-bottom: 20px; }
@media (max-width:800px) { .nav__links, .cta { display:none; } }
/* Page-specific styles for ModerationPage */
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; }
.moderation-page { font-family: 'Open Sans', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
.moderation-content h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
.moderation-error { color:#dc3545; }
@ -50,7 +240,7 @@
}
/* Buttons */
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:0.85rem; transition:background-color 0.2s; flex:1; min-width:80px; }
.btn { padding:12px 30px; border:none; border-radius:6px; cursor:pointer; font-size:16px; transition:background-color 0.2s; min-width:80px; }
.btn-secondary { background:#6c757d; color:white; }
.btn-secondary:hover { background:#5a6268; }
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
@ -61,7 +251,6 @@
.btn-warning:hover { background:#e0a800; }
.btn-danger { background:#dc3545; color:white; }
.btn-danger:hover { background:#c82333; }
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
.btn:disabled { opacity:0.65; cursor:not-allowed; }
/* Modal */
@ -102,3 +291,32 @@
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
.admin-auth-form { width:100%; }
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }
/* ============================================
MATERIAL-UI OVERRIDES - Globale Schriftart
============================================ */
/* TextField, Input, Textarea */
.MuiTextField-root input,
.MuiTextField-root textarea,
.MuiInputBase-root,
.MuiInputBase-input,
.MuiOutlinedInput-input {
font-family: 'Open Sans', sans-serif !important;
}
/* Labels */
.MuiFormLabel-root,
.MuiInputLabel-root,
.MuiTypography-root {
font-family: 'Open Sans', sans-serif !important;
}
/* Buttons */
.MuiButton-root {
font-family: 'Open Sans', sans-serif !important;
}
/* Checkbox Labels */
.MuiFormControlLabel-label {
font-family: 'Open Sans', sans-serif !important;
}

View File

@ -3,11 +3,12 @@ import './App.css';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
import { getHostConfig } from './Utils/hostDetection.js';
import ErrorBoundary from './Components/ComponentUtils/ErrorBoundary.js';
// Always loaded (public + internal)
import MultiUploadPage from './Components/Pages/MultiUploadPage';
import ManagementPortalPage from './Components/Pages/ManagementPortalPage';
import NotFoundPage from './Components/Pages/404Page.js';
import ErrorPage from './Components/Pages/ErrorPage.js';
// Lazy loaded (internal only) - Code Splitting für Performance
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
@ -18,14 +19,14 @@ const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/Moderati
/**
* Protected Route Component
* Redirects to upload page if accessed from public host
* Shows 403 page if accessed from public host
*/
const ProtectedRoute = ({ children }) => {
const hostConfig = getHostConfig();
if (hostConfig.isPublic) {
// Redirect to upload page - feature not available on public
return <Navigate to="/" replace />;
// Show 403 - feature not available on public
return <ErrorPage errorCode="403" />;
}
return children;
@ -52,6 +53,7 @@ function App() {
const hostConfig = getHostConfig();
return (
<ErrorBoundary>
<AdminSessionProvider>
<Router>
<Suspense fallback={<LoadingFallback />}>
@ -60,9 +62,14 @@ function App() {
<Route path="/" element={<MultiUploadPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} />
{/* Internal Only Routes - nur auf internal host geladen */}
{hostConfig.isInternal && (
<>
{/* Error Pages */}
<Route path="/error/403" element={<ErrorPage errorCode="403" />} />
<Route path="/error/404" element={<ErrorPage errorCode="404" />} />
<Route path="/error/500" element={<ErrorPage errorCode="500" />} />
<Route path="/error/502" element={<ErrorPage errorCode="502" />} />
<Route path="/error/503" element={<ErrorPage errorCode="503" />} />
{/* Internal Only Routes - geschützt durch ProtectedRoute */}
<Route
path="/slideshow"
element={
@ -103,15 +110,14 @@ function App() {
</ProtectedRoute>
}
/>
</>
)}
{/* 404 / Not Found */}
<Route path="*" element={<NotFoundPage />} />
<Route path="*" element={<ErrorPage errorCode="404" />} />
</Routes>
</Suspense>
</Router>
</AdminSessionProvider>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,54 @@
.consent-filter-container {
margin-bottom: 24px;
}
.consent-filter-title {
margin-bottom: 16px;
border-bottom: 2px solid #e9ecef;
font-size: 1.5rem;
font-weight: 600;
color: #333;
}
.consent-filter {
min-width: 250px;
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
}
.consent-filter-legend {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.filter-icon {
margin-right: 4px;
font-size: 18px;
}
.consent-filter-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.consent-filter-label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
.consent-filter-label:hover {
color: #4CAF50;
}
.consent-filter-checkbox {
margin-right: 8px;
cursor: pointer;
}

View File

@ -0,0 +1,80 @@
import React from 'react';
import FilterListIcon from '@mui/icons-material/FilterList';
import './ConsentFilter.css';
/**
* ConsentFilter Component
* Displays checkboxes for filtering groups by consent type
*
* @param {Object} filters - Current filter state { workshop, facebook, instagram, tiktok }
* @param {Function} onChange - Callback when filter changes
* @param {Array} platforms - Available social media platforms from API
*/
const ConsentFilter = ({ filters, onChange, platforms = [] }) => {
const handleCheckboxChange = (filterName, checked) => {
onChange({
...filters,
[filterName]: checked
});
};
// Platform mapping for display names
const platformLabels = {
workshop: 'Werkstatt',
facebook: 'Facebook',
instagram: 'Instagram',
tiktok: 'TikTok'
};
return (
<div className="consent-filter-container">
<h2 className="consent-filter-title">Filter</h2>
<fieldset className="consent-filter">
<legend className="consent-filter-legend">
<FilterListIcon className="filter-icon" />
Consent-Filter
</legend>
<div className="consent-filter-options">
<label className="consent-filter-label">
<input
type="checkbox"
checked={filters.workshop}
onChange={(e) => handleCheckboxChange('workshop', e.target.checked)}
className="consent-filter-checkbox"
/>
{platformLabels.workshop}
</label>
<label className="consent-filter-label">
<input
type="checkbox"
checked={filters.facebook}
onChange={(e) => handleCheckboxChange('facebook', e.target.checked)}
className="consent-filter-checkbox"
/>
{platformLabels.facebook}
</label>
<label className="consent-filter-label">
<input
type="checkbox"
checked={filters.instagram}
onChange={(e) => handleCheckboxChange('instagram', e.target.checked)}
className="consent-filter-checkbox"
/>
{platformLabels.instagram}
</label>
<label className="consent-filter-label">
<input
type="checkbox"
checked={filters.tiktok}
onChange={(e) => handleCheckboxChange('tiktok', e.target.checked)}
className="consent-filter-checkbox"
/>
{platformLabels.tiktok}
</label>
</div>
</fieldset>
</div>
);
};
export default ConsentFilter;

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Box, Alert, Typography } from '@mui/material';
import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes';
import { apiFetch } from '../../Utils/apiFetch';
/**
* Manages consents with save functionality
@ -148,7 +149,7 @@ function ConsentManager({
// Save each change
for (const change of changes) {
const res = await fetch(`/api/manage/${token}/consents`, {
const res = await apiFetch(`/api/manage/${token}/consents`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
@ -235,11 +236,11 @@ function ConsentManager({
{/* Email Hint after successful save */}
{showEmailHint && successMessage && (
<Alert severity="info" sx={{ mt: 2 }}>
<strong>Wichtig:</strong> Bitte senden Sie jetzt eine E-Mail an{' '}
<strong>Wichtig:</strong> Bitte sende eine E-Mail an{' '}
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
info@hobbyhimmel.de
</a>{' '}
mit Ihrer Gruppen-ID, um die Löschung Ihrer Bilder auf den Social Media Plattformen anzufordern.
mit Deiner Gruppen-ID, um die Löschung Deiner Bilder auf den Social Media Plattformen anzufordern.
</Alert>
)}

View File

@ -3,7 +3,7 @@
text-align: right;
font-size: 11px;
color: #808080;
font-family: "Roboto", sans-serif;
font-family: "Open Sans", sans-serif;
font-weight: lighter;
margin: 0;
padding-right: 20px;
@ -23,7 +23,7 @@ footer {
footer a {
font-size: 11px;
color: #777;
font-family: "Roboto", sans-serif;
font-family: "Open Sans", sans-serif;
font-weight: lighter;
text-decoration: none;
transition: color 0.2s ease;

View File

@ -112,6 +112,8 @@
display: flex;
gap: 8px;
flex-wrap: wrap;
flex-direction: column;
margin-top: auto;
}
/* ImageGalleryCard - No preview state */
@ -185,7 +187,7 @@
.image-gallery-title {
margin-bottom: 15px;
font-family: 'Roboto', sans-serif;
font-family: 'Open Sans', sans-serif;
color: #333;
font-size: 1.5rem;
font-weight: 500;
@ -237,13 +239,18 @@
background: rgba(0,0,0,0.7);
color: white;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
padding: 8px 12px;
font-size: 16px;
cursor: grab;
user-select: none;
z-index: 10;
opacity: 0;
transition: opacity 0.2s;
opacity: 1; /* Always visible on mobile */
transition: opacity 0.2s, background 0.2s;
touch-action: none; /* Prevent scrolling when touching handle */
}
.drag-handle:hover {
background: rgba(0,0,0,0.9);
}
.image-gallery-card.reorderable:hover .drag-handle {
@ -294,7 +301,7 @@
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: 'Roboto', sans-serif;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
resize: vertical;
min-height: 50px;

View File

@ -9,7 +9,7 @@ header {
.logo {
margin-right: auto;
color: #ECF0F1;
font-family: 'Montserrat', sans-serif;
font-family: 'Open Sans', sans-serif;
font-size: 20px;
display: flex;
flex-direction: row;
@ -33,7 +33,7 @@ header {
.nav__links a,
.cta,
.overlay__content a {
font-family: "Montserrat", sans-serif;
font-family: "Open Sans", sans-serif;
font-weight: 500;
color: #edf0f1;
text-decoration: none;

View File

@ -3,6 +3,7 @@ import { Button } from '@mui/material';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../../Utils/apiFetch';
/**
* Delete group button with confirmation dialog
@ -41,7 +42,7 @@ function DeleteGroupButton({ token, groupName = 'diese Gruppe' }) {
try {
setDeleting(true);
const res = await fetch(`/api/manage/${token}`, {
const res = await apiFetch(`/api/manage/${token}`, {
method: 'DELETE'
});

View File

@ -0,0 +1,78 @@
/* ErrorAnimation Component Styles */
.error-animation-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 400px;
position: relative;
perspective: 1000px;
margin: 40px 0;
}
.error-rotor {
display: inline-block;
transform-origin: center;
transform-style: preserve-3d;
will-change: transform;
animation: errorRotateY 4s linear infinite;
}
.error-logo {
display: block;
width: 400px;
height: auto;
}
.error-logo #g136 {
transform-box: fill-box;
transform-origin: center;
will-change: transform;
animation: errorRotateSegments 3s linear infinite;
}
@keyframes errorRotateY {
0% {
transform: rotateY(0deg);
}
50% {
transform: rotateY(90deg);
}
100% {
transform: rotateY(0deg);
}
}
@keyframes errorRotateSegments {
0% {
transform: rotate3d(1, -1, 0, 0deg);
}
50% {
transform: rotate3d(1, -1, 0, 90deg);
}
100% {
transform: rotate3d(1, -1, 0, 0deg);
}
}
/* Responsive Sizing */
@media (max-width: 600px) {
.error-logo {
width: 300px;
}
.error-animation-container {
height: 300px;
}
}
@media (max-width: 400px) {
.error-logo {
width: 250px;
}
.error-animation-container {
height: 250px;
}
}

View File

@ -0,0 +1,146 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ErrorAnimation.css';
/**
* ErrorAnimation Component
* Zeigt eine animierte Wolke mit einem Fehlercode in Sieben-Segment-Anzeige
*
* @param {string} errorCode - Der anzuzeigende Fehlercode (z.B. "404", "403", "500")
*/
const ErrorAnimation = ({ errorCode = "404" }) => {
// Sieben-Segment-Mapping: welche Segmente für welche Ziffer leuchten
const segmentPatterns = {
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
'1': ['b', 'c'],
'2': ['a', 'b', 'd', 'e', 'g'],
'3': ['a', 'b', 'c', 'd', 'g'],
'4': ['b', 'c', 'f', 'g'],
'5': ['a', 'c', 'd', 'f', 'g'],
'6': ['a', 'c', 'd', 'e', 'f', 'g'],
'7': ['a', 'b', 'c'],
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
'9': ['a', 'b', 'c', 'd', 'f', 'g']
};
// Segment-Zuordnung zu Polygon-IDs (Position im Array = Segment)
const segmentOrder = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
// Fehlercode auf max 3 Ziffern begrenzen und mit Leerzeichen auffüllen
const displayCode = errorCode.toString().padStart(3, ' ').slice(0, 3);
const digits = displayCode.split('');
/**
* Bestimmt die Füllfarbe für ein Segment
* @param {string} digit - Die Ziffer (0-9 oder Leerzeichen)
* @param {string} segment - Das Segment (a-g)
* @returns {string} - Hex-Farbcode
*/
const getSegmentColor = (digit, segment) => {
if (digit === ' ') return '#ffffff'; // Leerzeichen = alle aus
const pattern = segmentPatterns[digit];
return pattern && pattern.includes(segment) ? '#76b043' : '#ffffff';
};
/**
* Generiert Polygon-Elemente für eine Ziffer
* @param {number} digitIndex - Position der Ziffer (0-2)
* @returns {JSX.Element[]} - Array von Polygon-Elementen
*/
const renderDigit = (digitIndex) => {
const digit = digits[digitIndex];
// Mapping: digitIndex 0 (links) = g1800, digitIndex 1 (mitte) = g1758, digitIndex 2 (rechts) = g1782
const baseIds = {
0: { group: 'g1800', polygons: ['polygon1786', 'polygon1788', 'polygon1790', 'polygon1792', 'polygon1794', 'polygon1796', 'polygon1798'] },
1: { group: 'g1758', polygons: ['polygon1573', 'polygon1575', 'polygon1577', 'polygon1579', 'polygon1581', 'polygon1583', 'polygon1585'] },
2: { group: 'g1782', polygons: ['polygon1768', 'polygon1770', 'polygon1772', 'polygon1774', 'polygon1776', 'polygon1778', 'polygon1780'] }
};
const transforms = {
0: 'translate(47.970487,-113.03641)',
1: 'translate(113.66502,-113.03641)',
2: 'translate(179.35956,-113.03641)'
};
const { group, polygons } = baseIds[digitIndex];
const transform = transforms[digitIndex];
const polyPoints = [
'20,20 10,10 20,0 60,0 70,10 60,20', // a (oben)
'60,20 70,10 80,20 80,40 70,50 60,40', // b (rechts oben)
'80,60 80,80 70,90 60,80 60,60 70,50', // c (rechts unten)
'20,80 60,80 70,90 60,100 20,100 10,90', // d (unten)
'10,80 0,90 -10,80 -10,60 0,50 10,60', // e (links unten)
'10,20 10,40 0,50 -10,40 -10,20 0,10', // f (links oben)
'20,60 10,50 20,40 60,40 70,50 60,60' // g (mitte)
];
const polyTransforms = [
'matrix(0.67523047,0,0,0.67523047,117.69293,49.286325)',
'matrix(0.67523047,0,0,0.67523047,119.69293,51.286325)',
'matrix(0.67523047,0,0,0.67523047,119.69293,55.286325)',
'matrix(0.67523047,0,0,0.67523047,117.69293,57.286325)',
'matrix(0.67523047,0,0,0.67523047,122.44524,55.286325)',
'matrix(0.67523047,0,0,0.67523047,122.44524,51.286325)',
'matrix(0.67523047,0,0,0.67523047,117.69293,53.286325)'
];
return (
<g id={group} transform={transform} key={digitIndex}>
{segmentOrder.map((segment, idx) => (
<polygon
key={polygons[idx]}
id={polygons[idx]}
points={polyPoints[idx]}
transform={polyTransforms[idx]}
style={{
fill: getSegmentColor(digit, segment),
fillOpacity: 1,
stroke: 'none',
strokeWidth: 2
}}
/>
))}
</g>
);
};
return (
<div className="error-animation-container">
<div className="error-rotor">
<svg
className="error-logo"
version="1.1"
viewBox="0 0 289.40499 170.09499"
>
{/* Wolke (g561) - bleibt immer gleich */}
<g id="g561" style={{ display: 'inline' }}>
<path
id="path1353"
style={{ display: 'inline', fill: '#48484a' }}
d="M 138.80469 0 C 97.587768 0 63.224812 29.321264 55.423828 68.242188 C 53.972832 68.119188 52.50934 68.042969 51.027344 68.042969 C 22.8464 68.042969 0 90.887413 0 119.06836 C 0 147.2483 22.8474 170.0957 51.027344 170.0957 C 65.865314 170.0957 210.51721 170.09375 225.61719 170.09375 C 260.84611 170.09375 289.4043 142.40467 289.4043 107.17773 C 289.4053 71.952807 260.84808 43.392578 225.61914 43.392578 C 221.50914 43.392578 217.49456 43.796064 213.60156 44.539062 C 199.2046 18.011166 171.10863 0 138.80469 0 z M 171.96289 40.238281 A 39.540237 71.54811 46.312638 0 1 192.97852 47.357422 A 39.540237 71.54811 46.312638 0 1 170.08984 124.95117 A 39.540237 71.54811 46.312638 0 1 90.582031 147.28711 A 39.540237 71.54811 46.312638 0 1 113.4707 69.695312 A 39.540237 71.54811 46.312638 0 1 171.96289 40.238281 z"
/>
</g>
{/* Sieben-Segment-Anzeige (g136) - dynamisch generiert */}
<g id="g136">
<g id="siebensegment" transform="matrix(0.46393276,-0.46393277,0.46393277,0.46393276,33.958225,228.89983)" style={{ display: 'inline' }}>
{renderDigit(0)}
{renderDigit(1)}
{renderDigit(2)}
</g>
</g>
</svg>
</div>
</div>
);
};
ErrorAnimation.propTypes = {
errorCode: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
])
};
export default ErrorAnimation;

View File

@ -0,0 +1,38 @@
import React from 'react';
import ErrorPage from '../Pages/ErrorPage';
/**
* Error Boundary Component
* Fängt React-Fehler ab und zeigt die 500-Error-Page an
*/
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log error details for debugging
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo
});
}
render() {
if (this.state.hasError) {
// Render 500 Error Page
return <ErrorPage errorCode="500" />;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -1,9 +1,10 @@
import React from 'react'
import packageJson from '../../../package.json'
import './Css/Footer.css'
function Footer() {
const version = window._env_?.APP_VERSION || '1.1.0';
const version = packageJson.version;
return (
<footer>

View File

@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
import DescriptionInput from './MultiUpload/DescriptionInput';
import { adminRequest } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import { apiFetch } from '../../Utils/apiFetch';
/**
* Manages group metadata with save functionality
@ -76,7 +77,7 @@ function GroupMetadataEditor({
if (isModerateMode) {
await adminRequest(endpoint, method, metadata);
} else {
const res = await fetch(endpoint, {
const res = await apiFetch(endpoint, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata)
@ -143,7 +144,7 @@ function GroupMetadataEditor({
>
{/* Component Header */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
📝 Projekt-Informationen
Projekt-Informationen
</Typography>
<DescriptionInput

View File

@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
import ImageGallery from './ImageGallery';
import { adminRequest } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import { apiFetch } from '../../Utils/apiFetch';
/**
* Manages image descriptions with save functionality
@ -49,7 +50,7 @@ function ImageDescriptionManager({
if (mode === 'moderate') {
await adminRequest(endpoint, 'DELETE');
} else {
const res = await fetch(endpoint, {
const res = await apiFetch(endpoint, {
method: 'DELETE'
});
@ -138,7 +139,7 @@ function ImageDescriptionManager({
if (mode === 'moderate') {
await adminRequest(endpoint, method, { descriptions });
} else {
const res = await fetch(endpoint, {
const res = await apiFetch(endpoint, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ descriptions })

View File

@ -5,6 +5,7 @@ import {
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
@ -34,11 +35,17 @@ const ImageGallery = ({
imageDescriptions = {},
onDescriptionChange = null
}) => {
// Sensors for drag and drop (touch-friendly)
// Sensors for drag and drop (desktop + mobile optimized)
const sensors = useSensors(
useSensor(TouchSensor, {
activationConstraint: {
delay: 0, // No delay - allow immediate dragging
tolerance: 0, // No tolerance - precise control
},
}),
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement before drag starts
distance: 5, // Require 5px movement before drag starts (desktop)
},
}),
useSensor(KeyboardSensor, {

View File

@ -221,71 +221,30 @@ const ImageGalleryCard = ({
mode === 'preview' ? (
// Preview mode actions (for upload preview)
<>
<button
className="btn btn-danger"
onClick={() => onDelete(itemId)}
>
🗑 Löschen
</button>
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑 Löschen</button>
{!isEditMode ? (
<button
className="btn btn-primary btn-sm"
onClick={() => onEditMode?.(true)}
>
Edit
</button>
<button className="btn btn-primary" onClick={() => onEditMode?.(true)}> Edit </button>
) : (
<button
className="btn btn-success btn-sm"
onClick={() => onEditMode?.(false)}
>
Fertig
</button>
<button className="btn btn-success" onClick={() => onEditMode?.(false)}> Fertig</button>
)}
</>
) : (
// Moderation mode actions (for existing groups)
<>
<button
className="btn btn-secondary"
onClick={() => onViewImages(item)}
>
Gruppe editieren
</button>
<button className="btn btn-secondary" onClick={() => onViewImages(item)}> Gruppe editieren</button>
{isPending ? (
<button
className="btn btn-success"
onClick={() => onApprove(itemId, true)}
>
Freigeben
</button>
<button className="btn btn-success" onClick={() => onApprove(itemId, true)}> Freigeben</button>
) : (
<button
className="btn btn-warning"
onClick={() => onApprove(itemId, false)}
>
Sperren
</button>
<button className="btn btn-warning" onClick={() => onApprove(itemId, false)}> Sperren</button>
)}
<button
className="btn btn-danger"
onClick={() => onDelete(itemId)}
>
🗑 Löschen
</button>
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑 Löschen</button>
</>
)
) : mode !== 'single-image' ? (
// Public view mode (only for group cards, not single images)
<button
className="view-button"
onClick={() => onViewImages(item)}
title="Anzeigen"
>
Anzeigen
</button>
<button className="view-button" onClick={() => onViewImages(item)} title="Anzeigen">Anzeigen</button>
) : null}
</div>
)}

View File

@ -9,9 +9,9 @@ const Loading = () => {
<div className="loading-logo-container">
<div className="rotor">
<svg
className="loading-logo"
class="loading-logo"
version="1.1"
viewBox="0 0 841.89 595.28"
viewBox="260 90 310 190"
xmlns="http://www.w3.org/2000/svg"
>
<g id="g136" display="inline">

View File

@ -17,7 +17,6 @@ function DescriptionInput({
const currentYear = new Date().getFullYear();
const fieldLabelSx = {
fontFamily: 'roboto',
fontSize: '14px',
color: '#555555',
marginBottom: '8px',
@ -25,7 +24,6 @@ function DescriptionInput({
};
const sectionTitleSx = {
fontFamily: 'roboto',
fontSize: '18px',
color: '#333333',
marginBottom: '15px',
@ -68,7 +66,7 @@ function DescriptionInput({
};
const requiredIndicatorSx = { color: '#E57373', fontSize: '16px' };
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px', fontStyle: 'italic' };
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px' };
return (
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}>

View File

@ -77,15 +77,13 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
const dropzoneTextSx = {
fontSize: '18px',
fontFamily: 'roboto',
color: '#666666',
margin: '10px 0'
};
const dropzoneSubtextSx = {
fontSize: '14px',
color: '#999999',
fontFamily: 'roboto'
color: '#999999'
};
const fileCountSx = {
@ -106,7 +104,7 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
onClick={handleClick}
>
<Typography sx={dropzoneTextSx}>
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
Mehrere Bilder hier hinziehen oder klicken zum Auswählen
</Typography>
<Typography sx={dropzoneSubtextSx}>

View File

@ -0,0 +1,46 @@
.stats-display-container {
margin-bottom: 24px;
}
.stats-title {
margin-bottom: 16px;
border-bottom: 2px solid #e9ecef;
font-size: 1.5rem;
font-weight: 600;
color: #333;
}
.stats-display {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.stat-item {
color: white;
padding: 24px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.stat-number {
display: block;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.stat-label {
display: block;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.95;
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import './StatsDisplay.css';
/**
* StatsDisplay Component
* Displays statistics in a grid layout
*
* @param {Array} stats - Array of stat objects { number, label }
*/
const StatsDisplay = ({ stats }) => {
return (
<div className="stats-display-container">
<h2 className="stats-title">Statistiken</h2>
<div className="stats-display">
{stats.map((stat, index) => (
<div key={index} className="stat-item">
<span className="stat-number">{stat.number}</span>
<span className="stat-label">{stat.label}</span>
</div>
))}
</div>
</div>
);
};
export default StatsDisplay;

File diff suppressed because one or more lines are too long

View File

@ -1,68 +0,0 @@
.container404{
margin-top: 25vh;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: nowrap;
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-content: center;
-ms-flex-line-pack: center;
align-content: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
}
.page404 {
width: 400px;
height: auto;
}
#tree{
stroke: #59513C;
}
#wood-stump{
stroke: #59513C;
-webkit-animation: wood-stump 3s infinite ease-in-out;
-moz-animation: wood-stump 3s infinite ease-in-out;
-o-animation: wood-stump 3s infinite ease-in-out;
animation: wood-stump 3s infinite ease-in-out;
}
@-webkit-keyframes wood-stump{ 0% { -webkit-transform: translate(100px) } 50% { -webkit-transform: translate(50px); } 100% { -webkit-transform: translate(100px); } }
@-moz-keyframes wood-stump{ 0% { -moz-transform: translate(100px); } 50% { -moz-transform: translate(50px); } 100% { -moz-transform: translate(100px); } }
@-o-keyframes wood-stump{ 0% { -o-transform: translate(100px); } 50% { -o-transform: translate(50px); } 100% { -o-transform: translate(100px); } }
@keyframes wood-stump{ 0% {-webkit-transform: translate(100px);-moz-transform: translate(100px);-ms-transform: translate(100px);transform: translate(100px); } 50% {-webkit-transform: translate(0px);-moz-transform: translate(0px);-ms-transform: translate(0px);transform: translate(0px); } 100% {-webkit-transform: translate(100px); -moz-transform: translate(100px);-ms-transform: translate(100px);transform: translate(100px); } }
#leaf{
stroke: #59513C;
-webkit-animation: leaf 7s infinite ease-in-out;
-moz-animation: leaf 7s infinite ease-in-out;
-o-animation: leaf 7s infinite ease-in-out;
animation: leaf 7s infinite ease-in-out;
}
@-webkit-keyframes leaf{ 0% { -webkit-transform: translate(0, 70px) } 50% { -webkit-transform: translate(0, 50px); } 100% { -webkit-transform: translate(0, 70px); } }
@-moz-keyframes leaf{ 0% { -moz-transform: translate(0, 70px); } 50% { -moz-transform: translate(0, 50px); } 100% { -moz-transform: translate(0, 70px); } }
@-o-keyframes leaf{ 0% { -o-transform: translate(0, 70px); } 50% { -o-transform: translate(0, 50px); } 100% { -o-transform: translate(0, 70px); } }
@keyframes leaf{ 0% {-webkit-transform: translate(0, 70px);-moz-transform: translate(0, 70px);-ms-transform: translate(0, 70px);transform: translate(0, 70px); } 50% {-webkit-transform: translate(0px);-moz-transform: translate(0px);-ms-transform: translate(0px);transform: translate(0px); } 100% {-webkit-transform: translate(0, 70px); -moz-transform: translate(0, 70px);-ms-transform: translate(0, 70px);transform: translate(0, 70px); } }
#border{
stroke: #59513C;
}
#Page{
fill: #59513C;
}
#notFound{
fill: #A7444B;
}

View File

@ -0,0 +1,9 @@
/* Error Pages Container */
.containerError {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
align-content: center;
align-items: center;
}

View File

@ -0,0 +1,179 @@
/* Moderation Page Layout */
.moderation-content {
padding-top: 20px;
}
.moderation-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
}
.moderation-user-info {
display: flex;
align-items: center;
gap: 16px;
}
.moderation-username {
color: #666666;
margin: 0;
font-size: 14px;
}
/* Filter Controls Area */
.moderation-controls {
display: flex;
gap: 16px;
margin-bottom: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
/* Sections */
.moderation-section {
margin-bottom: 32px;
}
/* Loading and Error States */
.moderation-loading,
.moderation-error {
text-align: center;
padding: 40px;
font-size: 18px;
}
.moderation-error {
color: #d32f2f;
background-color: #ffebee;
border-radius: 8px;
}
/* Image Modal */
.image-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.image-modal {
background: white;
border-radius: 12px;
max-width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
transition: color 0.2s;
}
.close-btn:hover {
color: #d32f2f;
}
.modal-body {
padding: 20px;
}
.group-details {
margin-bottom: 20px;
padding: 16px;
background-color: #f5f5f5;
border-radius: 8px;
}
.group-details p {
margin: 8px 0;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.image-item {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.image-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.modal-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.image-actions {
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fafafa;
}
.image-name {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
/* Responsive */
@media (max-width: 768px) {
.moderation-header {
flex-direction: column;
align-items: flex-start;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.image-modal {
max-width: 95%;
}
}

View File

@ -0,0 +1,56 @@
import React from 'react'
import Navbar from '../ComponentUtils/Headers/Navbar'
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload'
import ErrorAnimation from '../ComponentUtils/ErrorAnimation/ErrorAnimation'
import { getHostConfig } from '../../Utils/hostDetection'
import './Css/ErrorPage.css'
import '../../App.css'
const ERROR_MESSAGES = {
'403': {
title: '403 - Zugriff verweigert',
description: 'Sie haben keine Berechtigung, auf diese Ressource zuzugreifen.'
},
'404': {
title: '404 - Seite nicht gefunden',
description: 'Die angeforderte Seite existiert nicht.'
},
'500': {
title: '500 - Interner Serverfehler',
description: 'Es ist ein interner Serverfehler aufgetreten.'
},
'502': {
title: '502 - Bad Gateway',
description: 'Der Server hat eine ungültige Antwort erhalten.'
},
'503': {
title: '503 - Service nicht verfügbar',
description: 'Der Service ist vorübergehend nicht verfügbar.'
}
};
function ErrorPage({ errorCode = '404' }) {
const hostConfig = getHostConfig();
const error = ERROR_MESSAGES[errorCode] || ERROR_MESSAGES['404'];
return (
<div className="allContainerNoBackground">
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
<div className="containerError">
<div style={{ textAlign: 'center' }}>
<h1 style={{ textAlign: 'center' }}>{error.title}</h1>
<p>{error.description}</p>
<ErrorAnimation errorCode={errorCode} />
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
Zurück zur Startseite
</a>
</div>
</div>
</div>
)
}
export default ErrorPage

View File

@ -1,13 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import {
Container,
Card,
Typography,
Box,
CircularProgress
} from '@mui/material';
@ -63,14 +56,14 @@ function GroupsOverviewPage() {
return (
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" className="page-container">
<div className="loading-container">
<CircularProgress size={60} color="primary" />
<Typography variant="h6" style={{ marginTop: '20px', color: '#666666' }}>
Slideshows werden geladen...
</Typography>
<div className="container">
<div className="flex-center" style={{ minHeight: '400px' }}>
<div className="text-center">
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid #f3f3f3', borderTop: '4px solid #3498db', borderRadius: '50%', animation: 'spin 1s linear infinite', margin: '0 auto' }}></div>
<p className="mt-3" style={{ color: '#666666' }}>Slideshows werden geladen...</p>
</div>
</div>
</div>
</Container>
<Footer />
</div>
);
@ -86,53 +79,39 @@ function GroupsOverviewPage() {
</Helmet>
<Navbar />
<Container maxWidth="lg" className="page-container">
<div className="container page-container">
{/* Header */}
<Card className="header-card">
<Typography className="header-title">
Alle Slideshows
</Typography>
<Typography className="header-subtitle">
Übersicht aller erstellten Slideshows.
</Typography>
</Card>
<div className="card">
<h1 className="page-title">Alle Slideshows</h1>
<p className="page-subtitle">Übersicht aller erstellten Slideshows.</p>
</div>
{/* Groups Grid */}
{error ? (
<div className="empty-state">
<Typography variant="h6" style={{ color: '#f44336', marginBottom: '20px' }}>
😕 Fehler beim Laden
</Typography>
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
{error}
</Typography>
<h2 style={{ color: '#f44336' }} className="mb-3">😕 Fehler beim Laden</h2>
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
<button onClick={loadGroups} className="btn btn-secondary">
🔄 Erneut versuchen
</button>
</div>
) : groups.length === 0 ? (
<div className="empty-state">
<Typography variant="h4" style={{ color: '#666666', marginBottom: '20px' }}>
📸 Keine Slideshows vorhanden
</Typography>
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
<h2 style={{ color: '#666666' }} className="mb-3">📸 Keine Slideshows vorhanden</h2>
<p style={{ color: '#999999' }} className="mb-4">
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
</Typography>
<button
className="btn btn-success"
onClick={handleCreateNew}
style={{ fontSize: '16px', padding: '12px 24px' }}
>
</p>
<button className="btn btn-success" onClick={handleCreateNew}>
Erste Slideshow erstellen
</button>
</div>
) : (
<>
<Box marginBottom={2}>
<Typography variant="h6" style={{ color: '#666666' }}>
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
</Typography>
</Box>
<div className="mb-3">
<h3 style={{ color: '#666666' }}>
{groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
</h3>
</div>
<ImageGallery
items={groups}
onViewImages={(group) => handleViewGroup(group.groupId)}
@ -142,7 +121,7 @@ function GroupsOverviewPage() {
/>
</>
)}
</Container>
</div>
<div className="footerContainer">
<Footer />

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Container, Card, CardContent, Typography, Box, Button } from '@mui/material';
import Swal from 'sweetalert2';
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
import Footer from '../ComponentUtils/Footer';
@ -12,6 +11,7 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
import ConsentManager from '../ComponentUtils/ConsentManager';
import DeleteGroupButton from '../ComponentUtils/DeleteGroupButton';
import { apiFetch } from '../../Utils/apiFetch';
/**
* ManagementPortalPage - Self-service management for uploaded groups
@ -36,7 +36,7 @@ function ManagementPortalPage() {
setLoading(true);
setError(null);
const res = await fetch(`/api/manage/${token}`);
const res = await apiFetch(`/api/manage/${token}`);
if (res.status === 404) {
setError('Ungültiger oder abgelaufener Verwaltungslink');
@ -105,7 +105,7 @@ function ManagementPortalPage() {
formData.append('images', file);
});
const res = await fetch(`/api/manage/${token}/images`, {
const res = await apiFetch(`/api/manage/${token}/images`, {
method: 'POST',
body: formData
});
@ -146,7 +146,7 @@ function ManagementPortalPage() {
const imageIds = newOrder.map(img => img.id);
// Use token-based management API
const response = await fetch(`/api/manage/${token}/reorder`, {
const response = await apiFetch(`/api/manage/${token}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageIds: imageIds })
@ -179,9 +179,9 @@ function ManagementPortalPage() {
return (
<div className="allContainer">
<NavbarUpload />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div className="container flex-center" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
<Loading />
</Container>
</div>
<Footer />
</div>
);
@ -191,19 +191,15 @@ function ManagementPortalPage() {
return (
<div className="allContainer">
<NavbarUpload />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', textAlign: 'center' }}>
<Typography variant="h5" color="error" gutterBottom>
{error}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{error}
</Typography>
<Button variant="contained" onClick={() => navigate('/')}>
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
<div className="card text-center">
<h2 style={{ color: '#f44336' }} className="mb-2">{error}</h2>
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
<button className="btn btn-primary" onClick={() => navigate('/')}>
Zur Startseite
</Button>
</Card>
</Container>
</button>
</div>
</div>
<Footer />
</div>
);
@ -213,19 +209,17 @@ function ManagementPortalPage() {
<div className="allContainer">
<NavbarUpload />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
<CardContent>
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
Mein Upload verwalten
</Typography>
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
<div className="card mb-3">
<div className="card-content">
<h1 className="page-title text-center mb-2">Mein Upload verwalten</h1>
<p className="page-subtitle text-center mb-4">
Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern.
</Typography>
</p>
{/* Group Overview */}
{group && (
<Box sx={{ mb: 3 }}>
<div className="mb-4">
<ImageGalleryCard
item={group}
showActions={false}
@ -234,29 +228,25 @@ function ManagementPortalPage() {
hidePreview={true}
/>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
Erteilte Einwilligungen:
</Typography>
<div className="mt-3">
<h3 className="text-small" style={{ fontWeight: 600 }}>Erteilte Einwilligungen:</h3>
<ConsentBadges group={group} />
</Box>
</Box>
</div>
</div>
)}
{/* Add Images Dropzone */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
Weitere Bilder hinzufügen
</Typography>
<div className="mb-4">
<h3 className="mb-2" style={{ fontWeight: 600 }}>Weitere Bilder hinzufügen</h3>
<MultiImageDropzone
onImagesSelected={handleImagesSelected}
selectedImages={[]}
/>
</Box>
</div>
{/* Image Descriptions Manager */}
{group && group.images && group.images.length > 0 && (
<Box sx={{ mb: 3 }}>
<div className="mb-4">
<ImageDescriptionManager
images={group.images}
token={token}
@ -264,44 +254,44 @@ function ManagementPortalPage() {
onReorder={handleReorder}
onRefresh={loadGroup}
/>
</Box>
</div>
)}
{/* Group Metadata Editor */}
{group && (
<Box sx={{ mb: 3 }}>
<div className="mb-4">
<GroupMetadataEditor
initialMetadata={group.metadata}
token={token}
onRefresh={loadGroup}
/>
</Box>
</div>
)}
{/* Consent Manager */}
{group && (
<Box sx={{ mb: 3 }}>
<div className="mb-4">
<ConsentManager
initialConsents={group.consents}
token={token}
groupId={group.groupId}
onRefresh={loadGroup}
/>
</Box>
</div>
)}
{/* Delete Group Button */}
{group && (
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<div className="mt-4 flex-center">
<DeleteGroupButton
token={token}
groupName={group.title || group.name || 'diese Gruppe'}
/>
</Box>
</div>
)}
</CardContent>
</Card>
</Container>
</div>
</div>
</div>
<div className="footerContainer">
<Footer />

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Container, Box } from '@mui/material';
// Services
import { adminGet } from '../../services/adminApi';
import { adminGet, adminRequest } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
@ -15,6 +14,9 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
// UI
import Swal from 'sweetalert2';
/**
* ModerationGroupImagesPage - Admin page for moderating group images
*
@ -72,6 +74,35 @@ const ModerationGroupImagesPage = () => {
loadGroup();
}, [isAuthenticated, loadGroup]);
const handleReorder = async (newOrder) => {
if (!group || !groupId) {
console.error('No groupId available for reordering');
return;
}
try {
const imageIds = newOrder.map(img => img.id);
// Use admin API
await adminRequest(`/api/admin/groups/${groupId}/reorder`, 'PUT', {
imageIds: imageIds
});
await Swal.fire({
icon: 'success',
title: 'Gespeichert',
text: 'Die neue Reihenfolge wurde gespeichert.',
timer: 1500,
showConfirmButton: false
});
await loadGroup();
} catch (error) {
console.error('Error reordering images:', error);
await handleAdminError(error, 'Reihenfolge speichern');
}
};
const renderContent = () => {
if (loading) return <Loading />;
if (error) return <div className="moderation-error">{error}</div>;
@ -81,13 +112,15 @@ const ModerationGroupImagesPage = () => {
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
{/* Image Descriptions Manager */}
<ImageDescriptionManager
images={group.images}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
enableReordering={true}
onReorder={handleReorder}
/>
{/* Group Metadata Editor */}
@ -99,15 +132,15 @@ const ModerationGroupImagesPage = () => {
/>
{/* Back Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<div className="flex-center mt-4">
<button
className="btn btn-secondary"
onClick={() => navigate('/moderation')}
>
Zurück zur Übersicht
</button>
</Box>
</Container>
</div>
</div>
<div className="footerContainer"><Footer /></div>
</div>

View File

@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Typography } from '@mui/material';
import FilterListIcon from '@mui/icons-material/FilterList';
import Swal from 'sweetalert2/dist/sweetalert2.js';
// Services
@ -17,8 +15,14 @@ import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
import ImageGallery from '../ComponentUtils/ImageGallery';
import DeletionLogSection from '../ComponentUtils/DeletionLogSection';
import ConsentFilter from '../ComponentUtils/ConsentFilter/ConsentFilter';
import StatsDisplay from '../ComponentUtils/StatsDisplay/StatsDisplay';
import { getImageSrc } from '../../Utils/imageUtils';
// Styles
import './Css/ModerationGroupsPage.css';
import '../../App.css';
const ModerationGroupsPage = () => {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
@ -268,24 +272,17 @@ const ModerationGroupsPage = () => {
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
</Helmet>
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2,
mb: 3
}}>
<Typography variant="h4" component="h1">
Moderation
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<div className="container moderation-content">
<div className="moderation-header">
<h1>Moderation</h1>
<div className="moderation-user-info">
<button className="btn btn-success" onClick={exportConsentData}> Consent-Daten exportieren </button>
{user?.username && (
<Typography variant="body2" color="text.secondary">
<p className="moderation-username">
Eingeloggt als <strong>{user.username}</strong>
</Typography>
</p>
)}
<button
type="button"
className="btn btn-outline-secondary"
@ -295,98 +292,36 @@ const ModerationGroupsPage = () => {
>
{logoutPending ? 'Wird abgemeldet…' : 'Logout'}
</button>
</Box>
</Box>
<div className="moderation-stats">
<div className="stat-item">
<span className="stat-number">{pendingGroups.length}</span>
<span className="stat-label">Wartend</span>
</div>
<div className="stat-item">
<span className="stat-number">{approvedGroups.length}</span>
<span className="stat-label">Freigegeben</span>
</div>
<div className="stat-item">
<span className="stat-number">{groups.length}</span>
<span className="stat-label">Gesamt</span>
</div>
</div>
{/* Lösch-Historie */}
<section className="moderation-section">
<DeletionLogSection />
</section>
<StatsDisplay
stats={[
{ number: pendingGroups.length, label: 'Wartend' },
{ number: approvedGroups.length, label: 'Freigegeben' },
{ number: groups.length, label: 'Gesamt' }
]}
/>
{/* Filter und Export Controls */}
<Box sx={{
display: 'flex',
gap: 2,
mb: 3,
alignItems: 'center',
flexWrap: 'wrap'
}}>
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
Consent-Filter
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.workshop}
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
size="small"
<ConsentFilter
filters={consentFilters}
onChange={setConsentFilters}
platforms={platforms}
/>
}
label="Werkstatt"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.facebook}
onChange={(e) => setConsentFilters({...consentFilters, facebook: e.target.checked})}
size="small"
/>
}
label="Facebook"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.instagram}
onChange={(e) => setConsentFilters({...consentFilters, instagram: e.target.checked})}
size="small"
/>
}
label="Instagram"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.tiktok}
onChange={(e) => setConsentFilters({...consentFilters, tiktok: e.target.checked})}
size="small"
/>
}
label="TikTok"
/>
</FormGroup>
</FormControl>
<button
className="btn btn-success"
onClick={exportConsentData}
style={{
fontSize: '14px',
padding: '10px 20px'
}}
>
📥 Consent-Daten exportieren
</button>
</Box>
{/* Wartende Gruppen */}
<section className="moderation-section">
<ImageGallery
items={pendingGroups}
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
title={`Wartende Freigabe (${pendingGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
@ -400,7 +335,7 @@ const ModerationGroupsPage = () => {
<section className="moderation-section">
<ImageGallery
items={approvedGroups}
title={`Freigegebene Gruppen (${approvedGroups.length})`}
title={`Freigegebene Gruppen (${approvedGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
@ -410,10 +345,7 @@ const ModerationGroupsPage = () => {
/>
</section>
{/* Lösch-Historie */}
<section className="moderation-section">
<DeletionLogSection />
</section>
{/* Bilder-Modal */}
{showImages && selectedGroup && (
@ -426,7 +358,7 @@ const ModerationGroupsPage = () => {
onDeleteImage={deleteImage}
/>
)}
</Container>
</div>
<div className="footerContainer"><Footer /></div>
</div>
);
@ -471,7 +403,7 @@ const ImageModal = ({ group, onClose, onDeleteImage }) => {
<div className="image-actions">
<span className="image-name">{image.originalName}</span>
<button
className="btn btn-danger btn-sm"
className="btn btn-danger"
onClick={() => onDeleteImage(group.groupId, image.id)}
title="Bild löschen"
>

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, Typography, Container, Box } from '@mui/material';
// Components
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
@ -163,17 +162,17 @@ function MultiUploadPage() {
<div className="allContainer">
{<NavbarUpload />}
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
<CardContent>
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
<div className="container">
<div className="card">
<div className="card-content">
<h1 className="page-title">
Project Image Uploader
</Typography>
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
</h1>
<p className="page-subtitle">
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe dein Projekt in wenigen Worten.
<br />
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
</Typography>
Die Bilder werden nur in der Hobbyhimmel Werkstatt auf den Monitoren gezeigt, es wird an keine Dritten weiter gegeben, sofern du deine Einwilligung nicht erteilst.
</p>
{!uploading ? (
<>
@ -215,15 +214,11 @@ function MultiUploadPage() {
/>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', mt: 3, flexWrap: 'wrap' }}>
<div className="flex-center">
<button
className="btn btn-success"
onClick={handleUpload}
disabled={!canUpload()}
style={{
fontSize: '16px',
padding: '12px 30px'
}}
>
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
</button>
@ -231,14 +226,10 @@ function MultiUploadPage() {
<button
className="btn btn-secondary"
onClick={handleClearAll}
style={{
fontSize: '16px',
padding: '12px 30px'
}}
>
🗑 Alle entfernen
</button>
</Box>
</div>
</>
)}
</>
@ -254,85 +245,58 @@ function MultiUploadPage() {
/>
</>
) : (
<Box sx={{
mt: 4,
p: 3,
borderRadius: '12px',
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
color: 'white',
boxShadow: '0 4px 20px rgba(76, 175, 80, 0.4)',
animation: 'slideIn 0.5s ease-out',
'@keyframes slideIn': {
from: {
opacity: 0,
transform: 'translateY(-20px)'
},
to: {
opacity: 1,
transform: 'translateY(0)'
}
}
}}>
<Typography sx={{ fontSize: '28px', fontWeight: 'bold', mb: 1 }}>
<div className="success-box">
<h2>
Upload erfolgreich!
</Typography>
<Typography sx={{ fontSize: '18px', mb: 2 }}>
</h2>
<p>
{uploadResult?.imageCount || 0} Bild{uploadResult?.imageCount === 1 ? '' : 'er'} {uploadResult?.imageCount === 1 ? 'wurde' : 'wurden'} hochgeladen.
</Typography>
</p>
<Box sx={{ bgcolor: 'rgba(255,255,255,0.2)', borderRadius: '8px', p: 2, mb: 2 }}>
<Typography sx={{ fontSize: '14px', mb: 1 }}>
<div className="info-box">
<p className="text-small">
Ihre Referenz-Nummer:
</Typography>
<Typography sx={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', mb: 1 }}>
</p>
<p style={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', marginBottom: '8px' }}>
{uploadResult?.groupId}
</Typography>
<Typography sx={{ fontSize: '12px', opacity: 0.9 }}>
</p>
<p className="text-small" style={{ opacity: 0.9 }}>
Notieren Sie sich diese Nummer für spätere Anfragen an das Team.
</Typography>
</Box>
</p>
</div>
{uploadResult?.managementToken && (
<Box sx={{
bgcolor: 'rgba(255,255,255,0.95)',
borderRadius: '8px',
p: 2.5,
mb: 2,
border: '2px solid rgba(255,255,255,0.3)'
}}>
<Typography sx={{ fontSize: '16px', fontWeight: 'bold', mb: 1.5, color: '#2e7d32' }}>
<div className="info-box-highlight">
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#2e7d32' }}>
🔗 Verwaltungslink für Ihren Upload
</Typography>
<Typography sx={{ fontSize: '13px', mb: 1.5, color: '#333' }}>
</h3>
<p style={{ fontSize: '13px', marginBottom: '12px', color: '#333' }}>
Mit diesem Link können Sie später Ihre Bilder verwalten, Einwilligungen widerrufen oder die Gruppe löschen:
</Typography>
</p>
<Box sx={{
bgcolor: '#f5f5f5',
p: 1.5,
<div style={{
background: '#f5f5f5',
padding: '12px',
borderRadius: '6px',
mb: 1.5,
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: 1,
gap: '8px',
flexWrap: 'wrap'
}}>
<Typography sx={{
<p style={{
fontSize: '13px',
fontFamily: 'monospace',
color: '#1976d2',
wordBreak: 'break-all',
flex: 1,
minWidth: '200px'
minWidth: '200px',
margin: 0
}}>
{window.location.origin}/manage/{uploadResult.managementToken}
</Typography>
</p>
<button
className="btn btn-secondary"
style={{
fontSize: '12px',
padding: '6px 16px'
}}
onClick={() => {
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
@ -351,43 +315,39 @@ function MultiUploadPage() {
>
📋 Kopieren
</button>
</Box>
</div>
<Typography sx={{ fontSize: '11px', color: '#666', mb: 0.5 }}>
<strong>Wichtig:</strong> Bewahren Sie diesen Link sicher auf! Jeder mit diesem Link kann Ihren Upload verwalten.
</Typography>
<Typography sx={{ fontSize: '11px', color: '#666', fontStyle: 'italic' }}>
<strong>Hinweis:</strong> Über diesen Link können Sie nur die Bilder in der Werkstatt verwalten. Bereits auf Social Media Plattformen veröffentlichte Bilder müssen separat dort gelöscht werden.
</Typography>
</Box>
<p className="text-small" style={{ color: '#666', marginBottom: '4px' }}>
<strong>Wichtig:</strong> Bewahre diesen Link sicher auf! Jeder mit diesem Link kann Deinen Upload verwalten.
</p>
<p className="text-small" style={{ color: '#666', fontStyle: 'italic' }}>
<strong>Hinweis:</strong> Über diesen Link kannst Du nur die Bilder in der Werkstatt verwalten. Bereits auf Social Media Plattformen veröffentlichte Bilder müssen separat dort gelöscht werden.
</p>
</div>
)}
<Typography sx={{ fontSize: '13px', mb: 2, opacity: 0.95 }}>
<p style={{ fontSize: '13px', marginBottom: '16px', opacity: 0.95 }}>
Die Bilder werden geprüft und nach Freigabe auf dem Werkstatt-Monitor angezeigt.
{' '}Bei Social Media Einwilligung werden sie entsprechend veröffentlicht.
</Typography>
</p>
<Typography sx={{ fontSize: '12px', mb: 3, opacity: 0.9 }}>
<p style={{ fontSize: '12px', marginBottom: '24px', opacity: 0.9 }}>
<strong>Fragen oder Widerruf?</strong> Kontakt: <strong>it@hobbyhimmel.de</strong>
</Typography>
</p>
<button
className="btn btn-success"
style={{
fontSize: '16px',
padding: '12px 30px'
}}
onClick={() => window.location.reload()}
>
👍 Weitere Bilder hochladen
</button>
</Box>
</div>
)}
</div>
)}
</CardContent>
</Card>
</Container>
</div>
</div>
</div>
<div className="footerContainer">
<Footer />

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Container } from '@mui/material';
import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
import ImageGallery from '../ComponentUtils/ImageGallery';
import { apiFetch } from '../../Utils/apiFetch';
const PublicGroupImagesPage = () => {
@ -22,7 +22,7 @@ const PublicGroupImagesPage = () => {
try {
setLoading(true);
// Public endpoint (no moderation controls)
const res = await fetch(`/api/groups/${groupId}`);
const res = await apiFetch(`/api/groups/${groupId}`);
if (!res.ok) throw new Error('Nicht gefunden');
const data = await res.json();
setGroup(data);
@ -41,7 +41,7 @@ const PublicGroupImagesPage = () => {
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" className="page-container" style={{ marginTop: '40px' }}>
<div className="container page-container" style={{ marginTop: '40px' }}>
<ImageGalleryCard
item={group}
showActions={false}
@ -69,7 +69,7 @@ const PublicGroupImagesPage = () => {
return acc;
}, {}) : {}}
/>
</Container>
</div>
<div className="footerContainer"><Footer /></div>
</div>

View File

@ -1,11 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Typography,
Box,
CircularProgress,
IconButton
} from '@mui/material';
import {
Home as HomeIcon,
ExitToApp as ExitIcon
@ -172,12 +166,12 @@ function SlideshowPage() {
if (loading) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<CircularProgress sx={{ color: 'white', mb: 2 }} />
<Typography sx={{ color: 'white' }}>Slideshow wird geladen...</Typography>
</Box>
</Box>
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid rgba(255,255,255,0.3)', borderTop: '4px solid white', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '16px' }}></div>
<p style={{ color: 'white', margin: 0 }}>Slideshow wird geladen...</p>
</div>
</div>
);
}
@ -192,27 +186,27 @@ function SlideshowPage() {
if (error) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>{error}</Typography>
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>{error}</p>
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
<HomeIcon />
</IconButton>
</Box>
</Box>
</button>
</div>
</div>
);
}
if (!currentGroup || !currentImage) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>Keine Bilder verfügbar</Typography>
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>Keine Bilder verfügbar</p>
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
<HomeIcon />
</IconButton>
</Box>
</Box>
</button>
</div>
</div>
);
}
@ -275,41 +269,41 @@ function SlideshowPage() {
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
return (
<Box sx={fullscreenSx}>
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
{/* Navigation Buttons */}
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
<HomeIcon />
</IconButton>
</button>
<IconButton sx={exitButtonSx} onClick={() => navigate('/')} title="Slideshow beenden">
<button style={{ position: 'absolute', top: '20px', right: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Slideshow beenden" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
<ExitIcon />
</IconButton>
</button>
{/* Hauptbild */}
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
<img src={getImageSrc(currentImage, false)} alt={currentImage.originalName} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', transition: `opacity ${TRANSITION_TIME}ms ease-in-out`, opacity: fadeOut ? 0 : 1 }} />
{/* Bildbeschreibung (wenn vorhanden) */}
{currentImage.imageDescription && (
<Box sx={imageDescriptionSx}>
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography>
</Box>
<div style={{ position: 'fixed', bottom: '140px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'rgba(0,0,0,0.7)', padding: '15px 30px', borderRadius: '8px', maxWidth: '80%', textAlign: 'center', backdropFilter: 'blur(5px)', zIndex: 10002 }}>
<p style={{ color: 'white', fontSize: '18px', margin: 0, lineHeight: 1.4, fontFamily: 'Open Sans, sans-serif' }}>{currentImage.imageDescription}</p>
</div>
)}
{/* Beschreibung */}
<Box sx={descriptionContainerSx}>
<div style={{ position: 'fixed', left: '40px', bottom: '40px', backgroundColor: 'rgba(0,0,0,0.8)', padding: '25px 35px', borderRadius: '12px', maxWidth: '35vw', minWidth: '260px', textAlign: 'left', backdropFilter: 'blur(5px)', zIndex: 10001, boxShadow: '0 4px 24px rgba(0,0,0,0.4)' }}>
{/* Titel */}
<Typography sx={titleTextSx}>{currentGroup.title || 'Unbenanntes Projekt'}</Typography>
<h2 style={{ color: 'white', fontSize: '28px', fontWeight: 500, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.title || 'Unbenanntes Projekt'}</h2>
{/* Jahr und Name */}
<Typography sx={yearAuthorTextSx}>{currentGroup.year}{currentGroup.name && `${currentGroup.name}`}</Typography>
<p style={{ color: '#FFD700', fontSize: '18px', fontWeight: 400, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.year}{currentGroup.name && `${currentGroup.name}`}</p>
{/* Beschreibung (wenn vorhanden) */}
{currentGroup.description && <Typography sx={descriptionTextSx}>{currentGroup.description}</Typography>}
{currentGroup.description && <p style={{ color: '#E0E0E0', fontSize: '16px', fontWeight: 300, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif', lineHeight: 1.4 }}>{currentGroup.description}</p>}
{/* Meta-Informationen */}
<Typography sx={metaTextSx}>Bild {currentImageIndex + 1} von {currentGroup.images.length} Slideshow {currentGroupIndex + 1} von {allGroups.length}</Typography>
</Box>
</Box>
<p style={{ color: '#999', fontSize: '12px', marginTop: '8px', marginBottom: 0, fontFamily: 'Open Sans, sans-serif' }}>Bild {currentImageIndex + 1} von {currentGroup.images.length} Slideshow {currentGroupIndex + 1} von {allGroups.length}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
import axios from 'axios';
/**
* Axios instance with error handling interceptors
* Handles HTTP status codes and redirects to appropriate error pages
*/
// Create axios instance
const apiClient = axios.create({
baseURL: window._env_?.API_URL || '',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // For session cookies
});
/**
* Response interceptor for error handling
*/
apiClient.interceptors.response.use(
(response) => {
// Pass through successful responses
return response;
},
(error) => {
if (error.response) {
const { status } = error.response;
// Handle specific HTTP status codes
switch (status) {
case 403:
// Forbidden - redirect to 403 page
window.location.href = '/error/403';
break;
case 500:
// Internal Server Error - redirect to 500 page
window.location.href = '/error/500';
break;
case 502:
// Bad Gateway - redirect to 502 page
window.location.href = '/error/502';
break;
case 503:
// Service Unavailable - redirect to 503 page
window.location.href = '/error/503';
break;
default:
// For other errors, just reject the promise
break;
}
}
// Always reject the promise so calling code can handle it
return Promise.reject(error);
}
);
export default apiClient;

View File

@ -0,0 +1,116 @@
/**
* Enhanced Fetch Wrapper with Error Handling
* Automatically redirects to error pages based on HTTP status codes
*
* Note: adminApi.js uses its own adminFetch wrapper for CSRF token handling
* and should not be migrated to this wrapper.
*/
const handleErrorResponse = (status) => {
switch (status) {
case 403:
window.location.href = '/error/403';
break;
case 500:
window.location.href = '/error/500';
break;
case 502:
window.location.href = '/error/502';
break;
case 503:
window.location.href = '/error/503';
break;
default:
// Don't redirect for other errors (400, 401, etc.)
break;
}
};
/**
* Enhanced fetch with automatic error page redirects
* @param {string} url - The URL to fetch
* @param {object} options - Fetch options
* @returns {Promise<Response>} - The response object
*/
export const apiFetch = async (url, options = {}) => {
try {
const response = await fetch(url, {
...options,
credentials: options.credentials || 'include'
});
// If response is not ok, handle error
if (!response.ok) {
handleErrorResponse(response.status);
}
return response;
} catch (error) {
// Network errors or other fetch failures
console.error('Fetch error:', error);
throw error;
}
};
/**
* Helper for GET requests
*/
export const apiGet = async (url) => {
const response = await apiFetch(url, { method: 'GET' });
return response.json();
};
/**
* Helper for POST requests
*/
export const apiPost = async (url, body = null, options = {}) => {
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
if (body) {
fetchOptions.body = JSON.stringify(body);
}
const response = await apiFetch(url, fetchOptions);
return response.json();
};
/**
* Helper for PUT requests
*/
export const apiPut = async (url, body = null, options = {}) => {
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
if (body) {
fetchOptions.body = JSON.stringify(body);
}
const response = await apiFetch(url, fetchOptions);
return response.json();
};
/**
* Helper for DELETE requests
*/
export const apiDelete = async (url, options = {}) => {
const response = await apiFetch(url, {
method: 'DELETE',
...options
});
return response.json();
};
export default apiFetch;

View File

@ -1,3 +1,5 @@
import { apiFetch } from './apiFetch';
// Batch-Upload Funktion für mehrere Bilder
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
if (!images || images.length === 0) {
@ -29,7 +31,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
}
try {
const response = await fetch('/api/upload/batch', {
const response = await apiFetch('/api/upload/batch', {
method: 'POST',
body: formData
});
@ -50,7 +52,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
// Einzelne Gruppe abrufen
export const fetchGroup = async (groupId) => {
try {
const response = await fetch(`/api/groups/${groupId}`);
const response = await apiFetch(`/api/groups/${groupId}`);
if (!response.ok) {
const errorData = await response.json();
@ -67,7 +69,7 @@ export const fetchGroup = async (groupId) => {
// Alle Gruppen abrufen
export const fetchAllGroups = async () => {
try {
const response = await fetch('/api/groups');
const response = await apiFetch('/api/groups');
if (!response.ok) {
const errorData = await response.json();
@ -84,7 +86,7 @@ export const fetchAllGroups = async () => {
// Gruppe löschen
export const deleteGroup = async (groupId) => {
try {
const response = await fetch(`/api/groups/${groupId}`, {
const response = await apiFetch(`/api/groups/${groupId}`, {
method: 'DELETE'
});

View File

@ -1,4 +1,4 @@
import axios from 'axios'
import apiClient from './apiClient'
//import swal from 'sweetalert';
import Swal from 'sweetalert2/dist/sweetalert2.js'
@ -22,7 +22,7 @@ export async function sendRequest(file, handleLoading, handleResponse) {
handleLoading()
try {
const res = await axios.post(window._env_.API_URL + '/upload', formData, {
const res = await apiClient.post('/upload', formData, {
headers: {
"Content-Type": "multipart/form-data"
}

29468
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "project-image-uploader",
"version": "2.0.0",
"private": true,
"scripts": {
"release": "./scripts/release.sh patch",
"release:patch": "./scripts/release.sh patch",
"release:minor": "./scripts/release.sh minor",
"release:major": "./scripts/release.sh major"
},
"workspaces": [
"frontend",
"backend"
]
}

32
prod.sh
View File

@ -52,8 +52,36 @@ case $choice in
;;
3)
echo -e "${GREEN}Pushe Production Images zur Registry...${NC}"
docker compose -f docker/prod/docker-compose.yml push
echo -e "${GREEN}Production Images erfolgreich gepusht!${NC}"
# Hole aktuelle Version aus package.json
VERSION=$(node -p "require('./frontend/package.json').version")
REGISTRY="gitea.lan.hobbyhimmel.de/hobbyhimmel"
echo -e "${BLUE}Aktuelle Version: ${VERSION}${NC}"
echo -e "${BLUE}Registry: ${REGISTRY}${NC}"
# Baue Images mit korrekter Version
echo -e "${BLUE}Baue Images mit Version ${VERSION}...${NC}"
cd docker/prod
docker build -t ${REGISTRY}/image-uploader-frontend:${VERSION} -f frontend/Dockerfile ../../
docker build -t ${REGISTRY}/image-uploader-backend:${VERSION} -f backend/Dockerfile ../../
# Tag als 'latest'
docker tag ${REGISTRY}/image-uploader-frontend:${VERSION} ${REGISTRY}/image-uploader-frontend:latest
docker tag ${REGISTRY}/image-uploader-backend:${VERSION} ${REGISTRY}/image-uploader-backend:latest
# Push Images (max-concurrent-uploads=1 in Docker Desktop gesetzt)
echo -e "${BLUE}Pushe Frontend ${VERSION}...${NC}"
docker push ${REGISTRY}/image-uploader-frontend:${VERSION}
echo -e "${BLUE}Pushe Frontend latest...${NC}"
docker push ${REGISTRY}/image-uploader-frontend:latest
echo -e "${BLUE}Pushe Backend ${VERSION}...${NC}"
docker push ${REGISTRY}/image-uploader-backend:${VERSION}
echo -e "${BLUE}Pushe Backend latest...${NC}"
docker push ${REGISTRY}/image-uploader-backend:latest
cd ../..
echo -e "${GREEN}✓ Images v${VERSION} und latest erfolgreich gepusht!${NC}"
;;
4)
echo -e "${GREEN}Baue Container neu...${NC}"

View File

@ -0,0 +1,15 @@
# Telegram Bot Configuration Template
#
# Kopiere diese Datei zu .env.telegram und trage deine echten Werte ein:
# cp .env.telegram.example .env.telegram
#
# WICHTIG: .env.telegram NIEMALS committen! (ist in .gitignore)
# Bot-Token von @BotFather
# Beispiel: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Chat-ID der Telegram-Gruppe (negativ für Gruppen!)
# Ermitteln via: https://api.telegram.org/bot<TOKEN>/getUpdates
# Beispiel: -1001234567890
TELEGRAM_CHAT_ID=YOUR_CHAT_ID_HERE

View File

@ -1,4 +1,120 @@
# Scripts Overview
# Scripts
## 🚀 Automated Release (EMPFOHLEN)
### Ein Befehl macht alles:
```bash
npm run release # Patch: 1.2.0 → 1.2.1
npm run release:minor # Minor: 1.2.0 → 1.3.0
npm run release:major # Major: 1.2.0 → 2.0.0
```
**Was passiert automatisch:**
1. ✅ Version in allen package.json erhöht
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
4. ✅ Git Commit erstellt
5. ✅ Git Tag erstellt
6. ✅ Preview anzeigen + Bestätigung
Dann nur noch:
```bash
git push && git push --tags
```
### Beispiel-Workflow:
```bash
# Features entwickeln mit Conventional Commits:
git commit -m "feat: Add user login"
git commit -m "fix: Fix button alignment"
git commit -m "refactor: Extract ConsentFilter component"
# Release erstellen:
npm run release:minor
# Preview wird angezeigt, dann [Y] drücken
# Push:
git push && git push --tags
```
### CHANGELOG wird automatisch aus Commits generiert!
Das Script gruppiert deine Commits nach Typ:
- `feat:` → ✨ Features
- `fix:` → 🐛 Fixes
- `refactor:` → ♻️ Refactoring
- `chore:` → 🔧 Chores
- `docs:` → 📚 Documentation
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
---
## Manual Scripts
Falls du manuell Kontrolle brauchst:
### Version Management
### Quick Start
```bash
# Version erhöhen (patch: 1.2.0 → 1.2.1)
./scripts/bump-version.sh patch
# Version erhöhen (minor: 1.2.0 → 1.3.0)
./scripts/bump-version.sh minor
# Version erhöhen (major: 1.2.0 → 2.0.0)
./scripts/bump-version.sh major
# Nur synchronisieren (ohne Bump)
./scripts/sync-version.sh
```
### Workflow
1. **Version erhöhen:**
```bash
./scripts/bump-version.sh patch # oder minor/major
```
2. **CHANGELOG.md manuell aktualisieren**
3. **Commit & Tag:**
```bash
git add -A
git commit -m "chore: bump version to v1.2.1"
git tag v1.2.1
git push && git push --tags
```
### Was wird synchronisiert?
- ✅ `frontend/package.json` → **Single Source of Truth**
- ✅ `backend/package.json`
- ✅ `frontend/src/Components/ComponentUtils/Footer.js` (Fallback)
- ✅ `backend/src/generate-openapi.js` (API Version)
- ✅ Docker Images (falls vorhanden)
- ✅ OpenAPI Spec wird neu generiert
### Scripts
#### `bump-version.sh`
Erhöht die Version in `frontend/package.json` und ruft `sync-version.sh` auf.
**Parameter:** `patch` | `minor` | `major`
#### `sync-version.sh`
Synchronisiert die Version aus `frontend/package.json` zu allen anderen Dateien.
Kann auch manuell aufgerufen werden, wenn du die Version direkt in `frontend/package.json` geändert hast.
---
## Other Scripts Overview
## Admin-Benutzer anlegen (Shell)

506
scripts/README.telegram.md Normal file
View File

@ -0,0 +1,506 @@
# Telegram Bot Setup & Testing Guide
## Übersicht
Diese Anleitung beschreibt Schritt-für-Schritt, wie du den Telegram Bot für den Image Uploader erstellst und testest.
**Phase 1:** Standalone-Test (ohne Backend-Integration)
---
## Voraussetzungen
- ✅ Windows 11 mit installiertem Telegram Desktop
- ✅ Telegram Account
- ✅ Node.js >= 18.x installiert
- ✅ Zugriff auf dieses Git-Repository
---
## 1. Telegram Bot erstellen
### 1.1 BotFather öffnen
1. **Telegram Desktop** öffnen
2. In der Suche eingeben: `@BotFather`
3. Chat mit **BotFather** öffnen (offizieller Bot mit blauem Haken ✓)
### 1.2 Neuen Bot erstellen
**Commands im BotFather-Chat eingeben:**
```
/newbot
```
**BotFather fragt nach Namen:**
```
Alright, a new bot. How are we going to call it?
Please choose a name for your bot.
```
**Antworten mit:**
```
Werkstatt Image Uploader Bot
```
**BotFather fragt nach Username:**
```
Good. Now let's choose a username for your bot.
It must end in `bot`. Like this, for example: TetrisBot or tetris_bot.
```
**Antworten mit (muss auf `bot` enden):**
```
werkstatt_uploader_bot
```
⚠️ **Falls Username vergeben:** Anderen Namen wählen (z.B. `werkstatt_upload_bot`, `hobbyhimmel_uploader_bot`)
### 1.3 Bot-Token speichern
**BotFather antwortet mit:**
```
Done! Congratulations on your new bot.
You will find it at t.me/werkstatt_uploader_bot.
You can now add a description, about section and profile picture for your bot.
Use this token to access the HTTP API:
123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
Keep your token secure and store it safely, it can be used by anyone to control your bot.
```
**Token kopieren** (z.B. `123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890`)
➡️ **Diesen Token brauchst du gleich für `.env.telegram`!**
---
## 2. Test-Telegram-Gruppe erstellen
### 2.1 Neue Gruppe erstellen
1. In Telegram: **Neuer Chat** → **Neue Gruppe**
2. Gruppennamen eingeben: `Werkstatt Upload Bot Test`
3. **Weiter** klicken
4. (Optional) Weitere Mitglieder hinzufügen (oder nur dich selbst)
5. **Erstellen** klicken
### 2.2 Bot zur Gruppe hinzufügen
1. Test-Gruppe öffnen
2. Auf Gruppennamen (oben) klicken → **Mitglieder hinzufügen**
3. Suche nach: `@werkstatt_uploader_bot` (dein Bot-Username)
4. Bot auswählen → **Hinzufügen**
**Telegram zeigt:**
```
werkstatt_uploader_bot wurde zur Gruppe hinzugefügt
```
### 2.3 Privacy Mode deaktivieren (WICHTIG!)
⚠️ **Ohne diesen Schritt sieht der Bot keine Gruppennachrichten!**
1. **BotFather** öffnen
2. Command eingeben: `/mybots`
3. Deinen Bot auswählen: `@werkstatt_uploader_bot`
4. **Bot Settings** klicken
5. **Group Privacy** klicken
6. **Turn off** klicken
**BotFather bestätigt:**
```
Privacy mode is disabled for <bot-name>.
All messages will now be sent to the bot.
```
### 2.4 Bot als Admin hinzufügen (optional, aber empfohlen)
1. Gruppe öffnen → Gruppennamen klicken
2. **Administratoren** → **Administrator hinzufügen**
3. `@werkstatt_uploader_bot` auswählen
4. **Berechtigungen:**
- ✅ Nachrichten senden
- ❌ Alle anderen optional (nicht nötig)
5. **Speichern**
---
## 3. Chat-ID ermitteln
Die Chat-ID wird benötigt, um Nachrichten an die richtige Gruppe zu senden.
### Methode 1: Via Telegram API (empfohlen)
**Schritt 1:** Nachricht in Test-Gruppe senden
- Öffne die Test-Gruppe
- Sende eine beliebige Nachricht (z.B. "Test")
**Schritt 2:** Browser öffnen und folgende URL aufrufen:
```
https://api.telegram.org/bot<DEIN_BOT_TOKEN>/getUpdates
```
**Ersetze `<DEIN_BOT_TOKEN>`** mit deinem echten Token!
**Beispiel:**
```
https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates
```
⚠️ **Wenn du `{"ok":true,"result":[]}` siehst (leeres result-Array):**
Das bedeutet, der Bot hat noch keine Nachrichten empfangen. **Checkliste:**
1. ✅ Hast du den Bot zur Gruppe hinzugefügt? (Schritt 2.2)
2. ✅ Hast du **NACH** dem Hinzufügen eine Nachricht gesendet? (Schritt 1)
3. ✅ War die Nachricht in der **richtigen Gruppe** (nicht im Bot-Direct-Chat)?
**Lösung - Mach jetzt folgendes:**
- Telegram öffnen
- **Test-Gruppe** öffnen (nicht den Bot direkt!)
- Prüfe, ob der Bot als Mitglied angezeigt wird
- Sende eine neue Nachricht: "Test"
- **Sofort** zurück zum Browser → Seite neu laden (F5)
**Schritt 3:** Im JSON-Response nach `chat` suchen:
```json
{
"ok": true,
"result": [
{
"update_id": 123456789,
"message": {
"message_id": 1,
"from": { ... },
"chat": {
"id": -1001234567890, // ← DAS IST DEINE CHAT-ID!
"title": "Werkstatt Upload Bot Test",
"type": "supergroup"
},
"text": "Test"
}
}
]
}
```
**Chat-ID kopieren** (z.B. `-1001234567890`)
⚠️ **Wichtig:** Gruppen-Chat-IDs sind **negativ** und beginnen meist mit `-100`!
### Methode 2: Via curl (Linux/WSL)
```bash
curl "https://api.telegram.org/bot<DEIN_BOT_TOKEN>/getUpdates" | jq '.result[0].message.chat.id'
```
---
## 4. Environment-Datei erstellen
### 4.1 Template kopieren
```bash
cd scripts
cp .env.telegram.example .env.telegram
```
### 4.2 `.env.telegram` bearbeiten
**Datei öffnen:** `scripts/.env.telegram`
```bash
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=-1001234567890
```
**Deine echten Werte eintragen:**
- `TELEGRAM_BOT_TOKEN` → Token von BotFather (Schritt 1.3)
- `TELEGRAM_CHAT_ID` → Chat-ID aus Schritt 3 (negativ!)
**Speichern** und schließen.
---
## 5. Dependencies installieren
```bash
cd scripts
# package.json initialisieren (falls nicht vorhanden)
npm init -y
# Telegram Bot API installieren
npm install node-telegram-bot-api dotenv
```
**Erwartete Ausgabe:**
```
added 15 packages, and audited 16 packages in 2s
```
---
## 6. Test-Script ausführen
### 6.1 Script starten
```bash
node telegram-test.js
```
### 6.2 Erwartete Ausgabe (Erfolg)
```
🔧 Lade Telegram-Konfiguration...
✅ Konfiguration geladen!
🤖 Verbinde mit Telegram Bot...
✅ Telegram Bot erfolgreich verbunden!
Bot-Details:
Name: Werkstatt Image Uploader Bot
Username: @werkstatt_uploader_bot
ID: 1234567890
📤 Sende Test-Nachricht an Chat -1001234567890...
✅ Nachricht erfolgreich gesendet!
Message-ID: 42
```
### 6.3 Telegram-Gruppe prüfen
**In der Test-Gruppe sollte jetzt erscheinen:**
```
🤖 Telegram Bot Test
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
Status: ✅ Erfolgreich verbunden!
Zeitstempel: 2025-11-29 14:23:45
---
Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
```
---
## 7. Troubleshooting
### ❌ Fehler: "Error: Unauthorized (401)"
**Ursache:** Bot-Token ist falsch oder ungültig
**Lösung:**
1. BotFather öffnen
2. `/token` eingeben
3. Deinen Bot auswählen
4. Neuen Token kopieren
5. `.env.telegram` aktualisieren
6. Script erneut starten
---
### ❌ Fehler: "Bad Request: chat not found"
**Ursache:** Chat-ID ist falsch
**Lösung:**
1. Test-Gruppe öffnen
2. Neue Nachricht senden
3. Chat-ID erneut ermitteln (Schritt 3)
4. `.env.telegram` aktualisieren
5. Script erneut starten
---
### ❌ Fehler: "Error: ETELEGRAM: 403 Forbidden"
**Ursache:** Bot wurde aus der Gruppe entfernt oder kann nicht posten
**Lösung:**
1. Test-Gruppe öffnen
2. Prüfen, ob Bot noch Mitglied ist
3. Falls nicht: Bot erneut hinzufügen (Schritt 2.2)
4. Falls ja: Bot als Admin hinzufügen (Schritt 2.3)
5. Script erneut starten
---
### ❌ Fehler: "Cannot find module 'node-telegram-bot-api'"
**Ursache:** Dependencies nicht installiert
**Lösung:**
```bash
cd scripts
npm install
node telegram-test.js
```
---
### ❌ Fehler: "TELEGRAM_BOT_TOKEN is not defined"
**Ursache:** `.env.telegram` fehlt oder nicht korrekt
**Lösung:**
1. Prüfen, ob `.env.telegram` existiert: `ls -la scripts/.env.telegram`
2. Falls nicht: Template kopieren (Schritt 4.1)
3. Werte eintragen (Schritt 4.2)
4. Script erneut starten
---
## 8. Erweiterte Tests
### Test 1: Formatierung mit Emojis
**Script anpassen:** `telegram-test.js`
```javascript
const message = `
📸 Neuer Upload!
Uploader: Max Mustermann
Bilder: 12
Gruppe: 2024 - Schweißkurs November
Workshop: ✅ Ja
Social Media: 📘 Instagram, 🎵 TikTok
🔗 Zur Freigabe: https://internal.hobbyhimmel.de/moderation
`;
bot.sendMessage(chatId, message);
```
**Ausführen:**
```bash
node telegram-test.js
```
**Telegram prüfen:** Emojis sollten korrekt angezeigt werden
---
### Test 2: HTML-Formatierung
**Script anpassen:**
```javascript
const message = `
<b>🤖 Telegram Bot Test</b>
<i>HTML-Formatierung funktioniert!</i>
<code>Status: ✅ Erfolgreich</code>
<a href="https://hobbyhimmel.de">Link zur Website</a>
`;
bot.sendMessage(chatId, message, { parse_mode: 'HTML' });
```
**Ausführen & prüfen:** Fetter Text, kursiver Text, Code, Link
---
### Test 3: Markdown-Formatierung
**Script anpassen:**
```javascript
const message = `
*🤖 Telegram Bot Test*
_Markdown-Formatierung funktioniert!_
\`Status: ✅ Erfolgreich\`
[Link zur Website](https://hobbyhimmel.de)
`;
bot.sendMessage(chatId, message, { parse_mode: 'Markdown' });
```
---
## 9. Sicherheit
### ⚠️ Wichtig!
- ❌ **NIEMALS** `.env.telegram` committen!
- ❌ **NIEMALS** Bot-Token öffentlich teilen!
- ✅ `.env.telegram` ist in `.gitignore` eingetragen
- ✅ Nur `.env.telegram.example` (ohne echte Tokens) committen
### Bot-Token kompromittiert?
**Falls Token versehentlich exposed:**
1. BotFather öffnen
2. `/revoke` eingeben
3. Deinen Bot auswählen
4. **Neuen Token** kopieren
5. `.env.telegram` aktualisieren
6. Alle Services neu starten
---
## 10. Nächste Schritte
### ✅ Phase 1 abgeschlossen?
Checklist:
- [x] Bot erstellt
- [x] Test-Gruppe erstellt
- [x] Bot zur Gruppe hinzugefügt
- [x] Chat-ID ermittelt
- [x] `.env.telegram` konfiguriert
- [x] `npm install` erfolgreich
- [x] `node telegram-test.js` läuft ohne Fehler
- [x] Test-Nachricht in Telegram empfangen
### ➡️ Weiter zu Phase 2
**Backend-Integration:**
1. `TelegramNotificationService.js` erstellen
2. Service in Docker Dev Environment integrieren
3. ENV-Variablen in Backend übertragen
4. Unit-Tests schreiben
**Siehe:** `FeatureRequests/FEATURE_PLAN-telegram.md`
---
## Referenzen
- [Telegram Bot API Dokumentation](https://core.telegram.org/bots/api)
- [node-telegram-bot-api (npm)](https://www.npmjs.com/package/node-telegram-bot-api)
- [BotFather Commands](https://core.telegram.org/bots#botfather)
---
## Support
**Bei Problemen:**
1. Troubleshooting-Sektion durchlesen (Schritt 7)
2. Telegram Bot API Logs prüfen
3. BotFather `/mybots` → Bot auswählen → API Token prüfen
4. Chat-ID erneut ermitteln
**Erfolgreicher Test? 🎉**
```bash
git add scripts/
git commit -m "feat: Add Telegram Bot standalone test (Phase 1)"
```

38
scripts/bump-version.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Bumpt die Version und synchronisiert alle Dateien
set -e
VERSION_TYPE=${1:-patch} # patch, minor, major
if [[ ! "$VERSION_TYPE" =~ ^(patch|minor|major)$ ]]; then
echo "❌ Ungültiger Version-Typ: $VERSION_TYPE"
echo "Verwendung: ./scripts/bump-version.sh [patch|minor|major]"
exit 1
fi
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${BLUE}🚀 Version Bump: ${YELLOW}${VERSION_TYPE}${NC}"
# 1. Frontend Version bumpen (als Single Source of Truth)
echo " ├─ Bumpe Frontend Version..."
cd frontend
npm version $VERSION_TYPE --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
cd ..
echo -e "${GREEN} ✓ Neue Version: ${NEW_VERSION}${NC}"
# 2. Alle anderen Stellen synchronisieren
./scripts/sync-version.sh
echo ""
echo -e "${GREEN}✅ Version erfolgreich auf v${NEW_VERSION} erhöht!${NC}"
echo ""
echo "Vergiss nicht:"
echo " 1. CHANGELOG.md für v${NEW_VERSION} aktualisieren"
echo " 2. Commit & Tag erstellen"

0
scripts/examples.sh Normal file → Executable file
View File

View File

@ -39,6 +39,8 @@ changed = False
cookie_pattern = re.compile(r'(\-\s*ADMIN_SESSION_COOKIE_SECURE\s*=\s*)([^\n\r]+)')
secret_pattern = re.compile(r'(\-\s*ADMIN_SESSION_SECRET\s*=\s*)([^\n\r]+)')
telegram_token_pattern = re.compile(r'(\-\s*TELEGRAM_BOT_TOKEN\s*=\s*)([^\n\r${}]+)')
telegram_chat_pattern = re.compile(r'(\-\s*TELEGRAM_CHAT_ID\s*=\s*)(-?\d{10,})')
def ensure_entry(text, *, pattern, value, anchor_line, expected_line, label):
match = pattern.search(text)
@ -80,6 +82,18 @@ if secret_expected not in new_text:
print('ERROR: Failed to ensure ADMIN_SESSION_SECRET uses environment variable in docker-compose.yml', file=sys.stderr)
sys.exit(4)
telegram_token_match = telegram_token_pattern.search(new_text)
if telegram_token_match and telegram_token_match.group(2).strip() not in ['${TELEGRAM_BOT_TOKEN}', '']:
print(f'ERROR: TELEGRAM_BOT_TOKEN contains hardcoded secret: {telegram_token_match.group(2)[:20]}...', file=sys.stderr)
print(' Use ${TELEGRAM_BOT_TOKEN} placeholder instead!', file=sys.stderr)
sys.exit(5)
telegram_chat_match = telegram_chat_pattern.search(new_text)
if telegram_chat_match:
print(f'ERROR: TELEGRAM_CHAT_ID contains hardcoded value: {telegram_chat_match.group(2)}', file=sys.stderr)
print(' Use ${TELEGRAM_CHAT_ID} placeholder instead!', file=sys.stderr)
sys.exit(6)
if changed:
path.write_text(new_text)
print('UPDATED')

20
scripts/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "telegram-test-scripts",
"version": "1.0.0",
"description": "Standalone Telegram Bot Testing Scripts for Image Uploader",
"main": "telegram-test.js",
"scripts": {
"test": "node telegram-test.js"
},
"keywords": [
"telegram",
"bot",
"testing"
],
"author": "Werkstatt Hobbyhimmel",
"license": "ISC",
"dependencies": {
"dotenv": "^17.2.3",
"node-telegram-bot-api": "^0.66.0"
}
}

200
scripts/release.sh Executable file
View File

@ -0,0 +1,200 @@
#!/bin/bash
# Automatisches Release-Script mit CHANGELOG-Generierung
set -e
VERSION_TYPE=${1:-patch}
CUSTOM_MESSAGE=${2:-""}
if [[ ! "$VERSION_TYPE" =~ ^(patch|minor|major)$ ]]; then
echo "❌ Ungültiger Version-Typ: $VERSION_TYPE"
echo "Verwendung: ./scripts/release.sh [patch|minor|major] [optional: custom message]"
exit 1
fi
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${BLUE}🚀 Automated Release: ${YELLOW}${VERSION_TYPE}${NC}"
echo ""
# 1. Hole aktuelle Version vom letzten Git-Tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
echo -e "${YELLOW}⚠️ Kein vorheriger Tag gefunden. Verwende Version aus package.json${NC}"
CURRENT_VERSION=$(node -p "require('./frontend/package.json').version")
else
# Entferne führendes "v" falls vorhanden
CURRENT_VERSION=${LAST_TAG#v}
fi
echo -e "📌 Aktuelle Version (Tag): ${CURRENT_VERSION}"
# 2. Berechne neue Version basierend auf dem Tag
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
case $VERSION_TYPE in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
patch)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo -e "📦 Neue Version: ${GREEN}${NEW_VERSION}${NC}"
echo ""
# 3. Setze neue Version in package.json
cd frontend
npm version $NEW_VERSION --no-git-tag-version > /dev/null
cd ..
# 3. Synchronisiere alle Dateien
echo "🔄 Synchronisiere Version überall..."
./scripts/sync-version.sh > /dev/null 2>&1
# 4. Sammle Commits seit letztem Tag
echo "📝 Generiere CHANGELOG-Eintrag..."
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
echo -e "${YELLOW}⚠️ Kein vorheriger Tag gefunden. Alle Commits werden verwendet.${NC}"
echo " Tipp: Erstelle rückwirkend einen Tag für die letzte Version:"
echo " git tag -a v1.1.0 <commit-hash> -m 'Release 1.1.0'"
echo ""
COMMITS=$(git log --oneline --no-merges)
else
echo "📋 Commits seit Tag $LAST_TAG werden verwendet"
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline --no-merges)
fi
# 5. Gruppiere Commits nach Typ
FEATURES=$(echo "$COMMITS" | grep "^[a-f0-9]* feat:" || true)
FIXES=$(echo "$COMMITS" | grep "^[a-f0-9]* fix:" || true)
REFACTOR=$(echo "$COMMITS" | grep "^[a-f0-9]* refactor:" || true)
CHORE=$(echo "$COMMITS" | grep "^[a-f0-9]* chore:" || true)
DOCS=$(echo "$COMMITS" | grep "^[a-f0-9]* docs:" || true)
# 6. Erstelle CHANGELOG-Eintrag
DATE=$(date +%Y-%m-%d)
CHANGELOG_ENTRY="## [${NEW_VERSION}] - ${DATE}\n\n"
if [ -n "$CUSTOM_MESSAGE" ]; then
CHANGELOG_ENTRY+="${CUSTOM_MESSAGE}\n\n"
fi
if [ -n "$FEATURES" ]; then
CHANGELOG_ENTRY+="### ✨ Features\n"
while IFS= read -r line; do
MSG=$(echo "$line" | sed -E 's/^[a-f0-9]+ feat: //')
CHANGELOG_ENTRY+="- ${MSG}\n"
done <<< "$FEATURES"
CHANGELOG_ENTRY+="\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG_ENTRY+="### 🐛 Fixes\n"
while IFS= read -r line; do
MSG=$(echo "$line" | sed -E 's/^[a-f0-9]+ fix: //')
CHANGELOG_ENTRY+="- ${MSG}\n"
done <<< "$FIXES"
CHANGELOG_ENTRY+="\n"
fi
if [ -n "$REFACTOR" ]; then
CHANGELOG_ENTRY+="### ♻️ Refactoring\n"
while IFS= read -r line; do
MSG=$(echo "$line" | sed -E 's/^[a-f0-9]+ refactor: //')
CHANGELOG_ENTRY+="- ${MSG}\n"
done <<< "$REFACTOR"
CHANGELOG_ENTRY+="\n"
fi
if [ -n "$CHORE" ]; then
CHANGELOG_ENTRY+="### 🔧 Chores\n"
while IFS= read -r line; do
MSG=$(echo "$line" | sed -E 's/^[a-f0-9]+ chore: //')
CHANGELOG_ENTRY+="- ${MSG}\n"
done <<< "$CHORE"
CHANGELOG_ENTRY+="\n"
fi
# 7. Füge Eintrag in CHANGELOG.md ein (nach der Überschrift)
if [ -f "CHANGELOG.md" ]; then
# Temporäre Datei erstellen
TEMP_FILE=$(mktemp)
# Erste Zeilen (bis erste ##) behalten
awk '/^## \[/ {exit} {print}' CHANGELOG.md > "$TEMP_FILE"
# Neuen Eintrag hinzufügen
echo -e "$CHANGELOG_ENTRY" >> "$TEMP_FILE"
# Rest des alten CHANGELOG anhängen
awk '/^## \[/ {found=1} found {print}' CHANGELOG.md >> "$TEMP_FILE"
# Ersetzen
mv "$TEMP_FILE" CHANGELOG.md
echo -e "${GREEN}✓ CHANGELOG.md aktualisiert${NC}"
else
# CHANGELOG erstellen
cat > CHANGELOG.md << EOF
# Changelog
Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
und dieses Projekt hält sich an [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
$CHANGELOG_ENTRY
EOF
echo -e "${GREEN}✓ CHANGELOG.md erstellt${NC}"
fi
# 8. Preview anzeigen
echo ""
echo -e "${BLUE}📄 CHANGELOG Preview:${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "$CHANGELOG_ENTRY" | head -20
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 9. Frage nach Bestätigung
read -p "Sieht das gut aus? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]] && [[ ! -z $REPLY ]]; then
echo "❌ Abgebrochen. Änderungen wurden NICHT committed."
exit 1
fi
# 10. Git Commit & Tag
echo ""
echo "📦 Erstelle Git Commit & Tag..."
git add -A
git commit -m "chore: release v${NEW_VERSION}
🔖 Version ${NEW_VERSION}
$(echo -e "$CHANGELOG_ENTRY" | sed 's/^## .*//' | sed 's/^$//' | head -30)"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
echo -e "${GREEN}✓ Commit & Tag erstellt${NC}"
echo ""
echo -e "${BLUE}Pushe zu Remote...${NC}"
# Push mit --follow-tags (pusht Commit + zugehörige Tags)
if git push --follow-tags; then
echo -e "${GREEN}✓ Erfolgreich gepusht${NC}"
else
echo -e "${RED}⚠ Push fehlgeschlagen - bitte manuell pushen:${NC}"
echo " git push --follow-tags"
fi
echo ""
echo -e "${GREEN}✅ Release v${NEW_VERSION} fertig!${NC}"

39
scripts/sync-version.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# Synchronisiert Versionsnummer über das gesamte Projekt
set -e
# Farben für Output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Hole Version aus frontend/package.json (als Single Source of Truth)
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version")
echo -e "${BLUE}📦 Synchronisiere Version: ${GREEN}${FRONTEND_VERSION}${NC}"
# 1. Backend package.json aktualisieren
echo " ├─ Backend package.json..."
cd backend
npm version $FRONTEND_VERSION --no-git-tag-version --allow-same-version
cd ..
# 2. OpenAPI generate-openapi.js aktualisieren
echo " ├─ Backend OpenAPI Spec..."
sed -i "s/version: '[0-9]\+\.[0-9]\+\.[0-9]\+'/version: '${FRONTEND_VERSION}'/" backend/src/generate-openapi.js
# 2. Docker Compose Files (optional, falls vorhanden)
if [ -f "docker/prod/docker-compose.yml" ]; then
echo " ├─ Docker Compose (prod)..."
sed -i "s/image: hobbyhimmel\/image-uploader-frontend:[0-9]\+\.[0-9]\+\.[0-9]\+/image: hobbyhimmel\/image-uploader-frontend:${FRONTEND_VERSION}/" docker/prod/docker-compose.yml || true
sed -i "s/image: hobbyhimmel\/image-uploader-backend:[0-9]\+\.[0-9]\+\.[0-9]\+/image: hobbyhimmel\/image-uploader-backend:${FRONTEND_VERSION}/" docker/prod/docker-compose.yml || true
fi
# 3. OpenAPI Spec neu generieren
echo " ├─ Regeneriere OpenAPI Spec..."
cd backend
npm run generate-openapi > /dev/null 2>&1
cd ..
echo -e "${GREEN}✓ Alle Versionen auf ${FRONTEND_VERSION} synchronisiert!${NC}"

166
scripts/telegram-test.js Normal file
View File

@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* Telegram Bot Standalone Test Script
*
* Testet die grundlegende Funktionalität des Telegram Bots
* ohne Integration in das Backend.
*
* Phase 1: Bot Setup & Standalone-Test
*
* Usage:
* node telegram-test.js
*
* Prerequisites:
* - .env.telegram mit TELEGRAM_BOT_TOKEN und TELEGRAM_CHAT_ID
* - npm install node-telegram-bot-api dotenv
*/
require('dotenv').config({ path: '.env.telegram' });
const TelegramBot = require('node-telegram-bot-api');
// =============================================================================
// Configuration
// =============================================================================
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
// =============================================================================
// Validation
// =============================================================================
console.log('🔧 Lade Telegram-Konfiguration...');
if (!BOT_TOKEN) {
console.error('❌ FEHLER: TELEGRAM_BOT_TOKEN ist nicht definiert!');
console.error('');
console.error('Bitte .env.telegram erstellen und Bot-Token eintragen:');
console.error('');
console.error(' TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz');
console.error(' TELEGRAM_CHAT_ID=-1001234567890');
console.error('');
console.error('Siehe: scripts/README.telegram.md');
process.exit(1);
}
if (!CHAT_ID) {
console.error('❌ FEHLER: TELEGRAM_CHAT_ID ist nicht definiert!');
console.error('');
console.error('Bitte .env.telegram erstellen und Chat-ID eintragen:');
console.error('');
console.error(' TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz');
console.error(' TELEGRAM_CHAT_ID=-1001234567890');
console.error('');
console.error('Chat-ID ermitteln: https://api.telegram.org/bot<TOKEN>/getUpdates');
console.error('Siehe: scripts/README.telegram.md');
process.exit(1);
}
console.log('✅ Konfiguration geladen!');
console.log('');
// =============================================================================
// Bot Initialization
// =============================================================================
console.log('🤖 Verbinde mit Telegram Bot...');
// Create bot instance (polling disabled for one-off script)
const bot = new TelegramBot(BOT_TOKEN, { polling: false });
// =============================================================================
// Main Test Function
// =============================================================================
async function runTest() {
try {
// Step 1: Verify bot connection
const botInfo = await bot.getMe();
console.log('✅ Telegram Bot erfolgreich verbunden!');
console.log('');
console.log('Bot-Details:');
console.log(` Name: ${botInfo.first_name}`);
console.log(` Username: @${botInfo.username}`);
console.log(` ID: ${botInfo.id}`);
console.log('');
// Step 2: Prepare test message
const timestamp = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const message = `
🤖 Telegram Bot Test
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
Status: Erfolgreich verbunden!
Zeitstempel: ${timestamp}
---
Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
`.trim();
// Step 3: Send test message
console.log(`📤 Sende Test-Nachricht an Chat ${CHAT_ID}...`);
const sentMessage = await bot.sendMessage(CHAT_ID, message);
console.log('✅ Nachricht erfolgreich gesendet!');
console.log('');
console.log(`Message-ID: ${sentMessage.message_id}`);
console.log('');
console.log('🎉 Test erfolgreich abgeschlossen!');
console.log('');
console.log('➡️ Nächste Schritte:');
console.log(' 1. Telegram-Gruppe öffnen und Nachricht prüfen');
console.log(' 2. Verschiedene Nachrichtenformate testen (siehe README.telegram.md)');
console.log(' 3. Phase 2 starten: Backend-Integration');
} catch (error) {
console.error('❌ FEHLER beim Senden der Nachricht:');
console.error('');
if (error.response && error.response.body) {
const errorData = error.response.body;
console.error(`Error Code: ${errorData.error_code}`);
console.error(`Description: ${errorData.description}`);
console.error('');
// Helpful error messages
if (errorData.error_code === 401) {
console.error('💡 Lösung: Bot-Token ist ungültig oder falsch.');
console.error(' → BotFather öffnen und Token prüfen');
console.error(' → .env.telegram aktualisieren');
} else if (errorData.error_code === 400 && errorData.description.includes('chat not found')) {
console.error('💡 Lösung: Chat-ID ist falsch oder ungültig.');
console.error(' → Chat-ID erneut ermitteln: https://api.telegram.org/bot<TOKEN>/getUpdates');
console.error(' → Neue Nachricht in Gruppe senden, dann getUpdates aufrufen');
console.error(' → .env.telegram aktualisieren');
} else if (errorData.error_code === 403) {
console.error('💡 Lösung: Bot hat keine Berechtigung, in diese Gruppe zu schreiben.');
console.error(' → Bot zur Gruppe hinzufügen');
console.error(' → Bot als Admin hinzufügen (empfohlen)');
}
} else {
console.error(error.message);
}
console.error('');
console.error('📖 Siehe Troubleshooting: scripts/README.telegram.md');
process.exit(1);
}
}
// =============================================================================
// Execute Test
// =============================================================================
runTest();

162
test-error-page.html Normal file
View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading Animation Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', Arial, sans-serif;
background-color: whitesmoke;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 40px;
text-align: center;
}
.demo-section {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
margin-bottom: 30px;
max-width: 800px;
width: 100%;
}
.demo-section h2 {
color: #333;
margin-bottom: 20px;
text-align: center;
font-size: 1.5rem;
}
.demo-description {
color: #666;
text-align: center;
margin-bottom: 30px;
font-size: 0.95rem;
}
/* Loading Animation Styles */
.loading-logo-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 400px;
position: relative;
perspective: 1000px;
}
.rotor {
display: inline-block;
transform-origin: center;
transform-style: preserve-3d;
will-change: transform;
animation: rotateY 4s linear infinite;
}
.loading-logo {
display: block;
width: 400px;
height: auto;
}
.loading-logo #g136 {
transform-box: fill-box;
transform-origin: center;
will-change: transform;
animation: rotateHammerAxis 3s linear infinite;
}
@keyframes rotateY {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
@keyframes rotateHammerAxis {
from {
transform: rotate3d(1, -1, 0, 0deg);
}
to {
transform: rotate3d(1, -1, 0, 360deg);
}
}
</style>
</head>
<body>
<h1>🎨 Loading Animation Test & Fehlerseiten-Design</h1>
<!-- Original Loading Animation -->
<div class="demo-section">
<h2>Original Loading Animation</h2>
<p class="demo-description">Die Standard-Loading-Animation mit grünem Hammer</p>
<div class="loading-logo-container">
<div class="rotor">
<svg
class="loading-logo"
version="1.1"
viewBox="0 0 289.40499 170.09499"
id="svg264"
>
<g id="g561" style="display:inline">
<path id="path1353" style="display:inline;fill:#48484a" d="M 138.80469 0 C 97.587768 0 63.224812 29.321264 55.423828 68.242188 C 53.972832 68.119188 52.50934 68.042969 51.027344 68.042969 C 22.8464 68.042969 0 90.887413 0 119.06836 C 0 147.2483 22.8474 170.0957 51.027344 170.0957 C 65.865314 170.0957 210.51721 170.09375 225.61719 170.09375 C 260.84611 170.09375 289.4043 142.40467 289.4043 107.17773 C 289.4053 71.952807 260.84808 43.392578 225.61914 43.392578 C 221.50914 43.392578 217.49456 43.796064 213.60156 44.539062 C 199.2046 18.011166 171.10863 0 138.80469 0 z M 171.96289 40.238281 A 39.540237 71.54811 46.312638 0 1 192.97852 47.357422 A 39.540237 71.54811 46.312638 0 1 170.08984 124.95117 A 39.540237 71.54811 46.312638 0 1 90.582031 147.28711 A 39.540237 71.54811 46.312638 0 1 113.4707 69.695312 A 39.540237 71.54811 46.312638 0 1 171.96289 40.238281 z "/>
</g>
<g id="g136">
<g id="siebensegment" transform="matrix(0.46393276,-0.46393277,0.46393277,0.46393276,33.958225,228.89983)" style="display:inline">
<g id="g1758" transform="translate(113.66502,-113.03641)">
<polygon points="20,20 10,10 20,0 60,0 70,10 60,20 " id="polygon1573" transform="matrix(0.67523047,0,0,0.67523047,117.69293,49.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="60,20 70,10 80,20 80,40 70,50 60,40 " id="polygon1575" transform="matrix(0.67523047,0,0,0.67523047,119.69293,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="80,60 80,80 70,90 60,80 60,60 70,50 " id="polygon1577" transform="matrix(0.67523047,0,0,0.67523047,119.69293,55.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="20,80 60,80 70,90 60,100 20,100 10,90 " id="polygon1579" transform="matrix(0.67523047,0,0,0.67523047,117.69293,57.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="10,80 0,90 -10,80 -10,60 0,50 10,60 " id="polygon1581" transform="matrix(0.67523047,0,0,0.67523047,122.44524,55.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="10,20 10,40 0,50 -10,40 -10,20 0,10 " id="polygon1583" transform="matrix(0.67523047,0,0,0.67523047,122.44524,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="20,60 10,50 20,40 60,40 70,50 60,60 " id="polygon1585" transform="matrix(0.67523047,0,0,0.67523047,117.69293,53.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
</g>
<g id="g1782" transform="translate(179.35956,-113.03641)">
<polygon points="70,10 60,20 20,20 10,10 20,0 60,0 " id="polygon1768" transform="matrix(0.67523047,0,0,0.67523047,117.69293,49.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="70,50 60,40 60,20 70,10 80,20 80,40 " id="polygon1770" transform="matrix(0.67523047,0,0,0.67523047,119.69293,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="60,60 70,50 80,60 80,80 70,90 60,80 " id="polygon1772" transform="matrix(0.67523047,0,0,0.67523047,119.69293,55.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="20,100 10,90 20,80 60,80 70,90 60,100 " id="polygon1774" transform="matrix(0.67523047,0,0,0.67523047,117.69293,57.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="0,50 10,60 10,80 0,90 -10,80 -10,60 " id="polygon1776" transform="matrix(0.67523047,0,0,0.67523047,122.44524,55.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="-10,20 0,10 10,20 10,40 0,50 -10,40 " id="polygon1778" transform="matrix(0.67523047,0,0,0.67523047,122.44524,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="70,50 60,60 20,60 10,50 20,40 60,40 " id="polygon1780" transform="matrix(0.67523047,0,0,0.67523047,117.69293,53.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
</g>
<g id="g1800" transform="translate(47.970487,-113.03641)">
<polygon points="60,20 20,20 10,10 20,0 60,0 70,10 " id="polygon1786" transform="matrix(0.67523047,0,0,0.67523047,117.69293,49.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="60,40 60,20 70,10 80,20 80,40 70,50 " id="polygon1788" transform="matrix(0.67523047,0,0,0.67523047,119.69293,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="70,50 80,60 80,80 70,90 60,80 60,60 " id="polygon1790" transform="matrix(0.67523047,0,0,0.67523047,119.69293,55.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="10,90 20,80 60,80 70,90 60,100 20,100 " id="polygon1792" transform="matrix(0.67523047,0,0,0.67523047,117.69293,57.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="10,60 10,80 0,90 -10,80 -10,60 0,50 " id="polygon1794" transform="matrix(0.67523047,0,0,0.67523047,122.44524,55.286325)" style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2"/>
<polygon points="0,10 10,20 10,40 0,50 -10,40 -10,20 " id="polygon1796" transform="matrix(0.67523047,0,0,0.67523047,122.44524,51.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
<polygon points="60,60 20,60 10,50 20,40 60,40 70,50 " id="polygon1798" transform="matrix(0.67523047,0,0,0.67523047,117.69293,53.286325)" style="fill:#76b043;stroke:none;stroke-width:2"/>
</g>
</g>
</g>
</svg>
</div>
</div>
</div>
</body>
</html>

150
test-loading.html Normal file
View File

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading Animation Test</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', Arial, sans-serif;
background-color: whitesmoke;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 40px;
text-align: center;
}
.demo-section {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
margin-bottom: 30px;
max-width: 800px;
width: 100%;
}
.demo-section h2 {
color: #333;
margin-bottom: 20px;
text-align: center;
font-size: 1.5rem;
}
.demo-description {
color: #666;
text-align: center;
margin-bottom: 30px;
font-size: 0.95rem;
}
.loading-logo-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 400px;
position: relative;
perspective: 1000px;
}
/* Äußerer Container: Y-Achsen-Rotation für Wolke UND Hammer zusammen */
.rotor {
display: inline-block;
transform-origin: center;
transform-style: preserve-3d;
will-change: transform;
animation: rotateY 4s linear infinite;
}
.loading-logo {
display: block;
width: 400px;
height: auto;
}
/* Hammer: zusätzliche Rotation um eigene Längsachse */
.loading-logo #g136 {
transform-box: fill-box; /* Bezieht sich auf eigene Bounding Box */
transform-origin: center; /* Mittelpunkt der eigenen BBox */
will-change: transform;
animation: rotateHammerAxis 3s linear infinite;
}
/* Y-Achsen-Rotation mit leichter X-Neigung (vermeidet Totpunkt bei 90°) */
@keyframes rotateY {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
/* Hammer-Rotation um eigene Längsachse (diagonal) */
@keyframes rotateHammerAxis {
from {
transform: rotate3d(1, -1, 0, 0deg);
}
to {
transform: rotate3d(1, -1, 0, 360deg);
}
}
</style>
</head>
<body>
<h1>🎨 Loading Animation Test & Fehlerseiten-Design</h1>
<!-- Original Loading Animation -->
<div class="demo-section">
<h2>Original Loading Animation</h2>
<p class="demo-description">Die Standard-Loading-Animation mit grünem Hammer</p>
<div class="loading-logo-container">
<div class="rotor">
<svg
class="loading-logo"
version="1.1"
viewBox="260 90 310 190"
xmlns="http://www.w3.org/2000/svg"
>
<g id="g136" display="inline">
<path
display="inline"
fill="#76b043"
d="m 386.456,248.659 c -0.824,0.825 -2.157,0.825 -2.987,0 L 365.572,230.76 c -0.818,-0.816 -0.818,-2.136 -0.005,-2.962 0.005,-0.008 0.005,-0.011 0.011,-0.019 0.006,-0.002 0.01,-0.002 0.017,-0.01 l 52.177,-52.177 20.877,20.876 z"
/>
<path
display="inline"
fill="#76b043"
d="m 473.015,185.95 c -0.021,0.018 -0.025,0.045 -0.043,0.063 -0.02,0.02 -0.045,0.022 -0.064,0.041 l -17.811,17.813 c -0.018,0.019 -0.023,0.046 -0.041,0.061 -0.02,0.02 -0.045,0.026 -0.064,0.045 -0.815,0.758 -2.064,0.754 -2.877,-0.012 -0.012,-0.014 -0.035,-0.018 -0.047,-0.033 -0.012,-0.012 -0.019,-0.033 -0.032,-0.046 l -49.265,-49.265 c -0.014,-0.016 -0.035,-0.02 -0.048,-0.034 -0.013,-0.011 -0.019,-0.034 -0.032,-0.049 -0.783,-0.826 -0.779,-2.121 0.032,-2.929 0.31,-0.312 0.698,-0.465 1.093,-0.543 l 0.004,-0.039 30.859,-5.149 0.035,0.034 c 0.607,-0.061 1.232,0.107 1.704,0.578 l 36.547,36.548 c 0.808,0.811 0.819,2.087 0.05,2.916"
/>
</g>
<g id="g561" display="inline">
<path
fill="#48484a"
d="m 501.16,142.979 c -4.11,0 -8.124,0.403 -12.017,1.146 -14.397,-26.528 -42.494,-44.539 -74.798,-44.539 -41.217,0 -75.58,29.322 -83.381,68.243 -1.451,-0.123 -2.914,-0.2 -4.396,-0.2 -28.181,0 -51.027,22.845 -51.027,51.026 0,28.18 22.847,51.026 51.027,51.026 14.838,0 159.491,-10e-4 174.591,-10e-4 35.229,0 63.787,-27.689 63.787,-62.916 10e-4,-35.225 -28.557,-63.785 -63.786,-63.785 M 386.432,248.707 c -0.824,0.825 -2.157,0.825 -2.987,0 l -17.897,-17.899 c -0.818,-0.816 -0.818,-2.136 -0.005,-2.962 0.005,-0.008 0.005,-0.011 0.011,-0.019 0.006,-0.002 0.01,-0.002 0.017,-0.01 l 52.177,-52.177 20.877,20.876 z m 86.558,-62.709 c -0.021,0.018 -0.025,0.045 -0.043,0.063 -0.02,0.02 -0.045,0.022 -0.064,0.041 l -17.811,17.813 c -0.018,0.019 -0.023,0.046 -0.041,0.061 -0.02,0.02 -0.045,0.026 -0.064,0.045 -0.815,0.758 -2.064,0.754 -2.877,-0.012 -0.012,-0.014 -0.035,-0.018 -0.047,-0.033 -0.012,-0.012 -0.019,-0.033 -0.032,-0.046 l -49.265,-49.265 c -0.014,-0.016 -0.035,-0.02 -0.048,-0.034 -0.013,-0.011 -0.019,-0.034 -0.032,-0.049 -0.783,-0.826 -0.779,-2.121 0.032,-2.929 0.31,-0.312 0.698,-0.465 1.093,-0.543 l 0.004,-0.039 30.859,-5.149 0.035,0.034 c 0.607,-0.061 1.232,0.107 1.704,0.578 l 36.547,36.548 c 0.809,0.811 0.82,2.087 0.05,2.916"
/>
</g>
</svg>
</div>
</div>
</div>
</body>
</html>