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
.env.local .env.local
# Telegram credentials
scripts/.env.telegram
scripts/node_modules/
scripts/package-lock.json
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/

View File

@ -1,6 +1,53 @@
# Changelog # 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) ### 🌐 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) ### 🔐 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) ### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
@ -370,7 +417,7 @@
--- ---
## [Unreleased] - Branch: feature/PreloadImage ## Preload Image
### 🚀 Slideshow Optimization (November 2025) ### 🚀 Slideshow Optimization (November 2025)
@ -407,7 +454,7 @@
--- ---
## [Unreleased] - Branch: feature/DeleteUnprovedGroups ## Delete Unproved Groups
### ✨ Automatic Cleanup Feature (November 2025) ### ✨ Automatic Cleanup Feature (November 2025)
@ -474,7 +521,7 @@
--- ---
## [Unreleased] - Branch: feature/ImageDescription ## Image Description
### ✨ Image Descriptions Feature (November 2025) ### ✨ 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) ### 🎯 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 # 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 ### API-Tests
```bash ```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 ## Nützliche Befehle
```bash ```bash

155
README.md
View File

@ -5,6 +5,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## Features ## Features
**Multi-Image Upload**: Upload multiple images at once with batch processing **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 **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 **Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content **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 ## 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. 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) See the [CHANGELOG](CHANGELOG.md) for a detailed list of improvements and new features.
- **🌐 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
## Quick Start ## Quick Start
@ -262,31 +176,31 @@ The application automatically generates optimized preview thumbnails for all upl
## Docker Structure ## 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/ docker/
├── .env.backend.example # Backend environment variables documentation ├── .env.backend.example # Backend environment variables documentation
├── .env.frontend.example # Frontend environment variables documentation ├── .env.frontend.example # Frontend environment variables documentation
├── dev/ # Development environment ├── 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/ │ ├── backend/
│ │ ├── config/.env # Development backend configuration
│ │ └── Dockerfile # Development backend container │ │ └── Dockerfile # Development backend container
│ └── frontend/ │ └── frontend/
│ ├── config/.env # Development frontend configuration │ ├── config/env.sh # Generates window._env_ from ENV
│ ├── config/env.sh # Runtime configuration script
│ ├── Dockerfile # Development frontend container │ ├── Dockerfile # Development frontend container
│ ├── nginx.conf # Development nginx configuration │ ├── nginx.conf # Development nginx configuration
│ └── start.sh # Development startup script │ └── start.sh # Development startup script
└── prod/ # Production environment └── 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/ ├── backend/
│ ├── config/.env # Production backend configuration
│ └── Dockerfile # Production backend container │ └── Dockerfile # Production backend container
└── frontend/ └── frontend/
├── config/.env # Production frontend configuration ├── config/env.sh # Generates window._env_ from ENV
├── config/env.sh # Runtime configuration script
├── config/htpasswd # HTTP Basic Auth credentials ├── config/htpasswd # HTTP Basic Auth credentials
├── Dockerfile # Production frontend container ├── Dockerfile # Production frontend container
└── nginx.conf # Production nginx configuration └── nginx.conf # Production nginx configuration
@ -294,6 +208,20 @@ docker/
### Environment Configuration ### 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 - **Development**: Uses `docker/dev/` configuration with live reloading
- **Production**: Uses `docker/prod/` configuration with optimized builds - **Production**: Uses `docker/prod/` configuration with optimized builds
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment - **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) For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
## Configuration ## Configuration
### Environment Variables ### 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 | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `API_URL` | `http://localhost:5001` | Backend API endpoint | | `API_URL` | `http://localhost:5001` | Backend API endpoint (frontend → backend) |
| `CLIENT_URL` | `http://localhost` | Frontend application URL | | `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 ### Volume Configuration
- **Upload Limits**: 100MB maximum file size for batch uploads - **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] 🎨 Drag & Drop Reihenfolge ändern
[x] 📊 Upload-Progress mit Details [x] 📊 Upload-Progress mit Details
[x] 🖼️ Thumbnail-Navigation in Slideshow [x] 🖼️ Thumbnail-Navigation in Slideshow
[ ] 🔄 Batch-Operations (alle entfernen, etc.)
### Future Features ### Future Features
- 👤 User-Management - 👤 User-Management

View File

@ -2,7 +2,7 @@
"openapi": "3.0.0", "openapi": "3.0.0",
"info": { "info": {
"title": "Project Image Uploader API", "title": "Project Image Uploader API",
"version": "1.0.0", "version": "2.0.1",
"description": "Auto-generated OpenAPI spec with correct mount prefixes" "description": "Auto-generated OpenAPI spec with correct mount prefixes"
}, },
"servers": [ "servers": [
@ -39,6 +39,9 @@
{ {
"name": "Admin - Cleanup" "name": "Admin - Cleanup"
}, },
{
"name": "Admin - Telegram"
},
{ {
"name": "Admin - Monitoring" "name": "Admin - Monitoring"
}, },
@ -385,6 +388,15 @@
}, },
"description": { "description": {
"example": "any" "example": "any"
},
"year": {
"example": "any"
},
"title": {
"example": "any"
},
"name": {
"example": "any"
} }
} }
} }
@ -1058,22 +1070,38 @@
}, },
"/api/manage/{token}/reorder": { "/api/manage/{token}/reorder": {
"put": { "put": {
"description": "", "tags": [
"Management Portal"
],
"summary": "Reorder images in group",
"description": "Reorder images within the managed group (token-based access)",
"parameters": [ "parameters": [
{ {
"name": "token", "name": "token",
"in": "path", "in": "path",
"required": true, "required": true,
"type": "string" "type": "string",
"description": "Management token (UUID v4)",
"example": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}, },
{ {
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true,
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"imageIds": { "imageIds": {
"example": "any" "type": "array",
"example": [
1,
3,
2,
4
],
"items": {
"type": "number"
}
} }
} }
} }
@ -1081,13 +1109,29 @@
], ],
"responses": { "responses": {
"200": { "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": { "400": {
"description": "Bad Request" "description": "Invalid token format or imageIds"
}, },
"404": { "404": {
"description": "Not Found" "description": "Token not found or group deleted"
}, },
"429": { "429": {
"description": "Too Many Requests" "description": "Too Many Requests"
@ -1151,25 +1195,46 @@
}, },
"/api/admin/groups/{groupId}/consents": { "/api/admin/groups/{groupId}/consents": {
"post": { "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": [ "parameters": [
{ {
"name": "groupId", "name": "groupId",
"in": "path", "in": "path",
"required": true, "required": true,
"type": "string" "type": "string",
"description": "Group ID",
"example": "abc123def456"
}, },
{ {
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true,
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"workshopConsent": { "workshopConsent": {
"example": "any" "type": "boolean",
"example": true
}, },
"socialMediaConsents": { "socialMediaConsents": {
"example": "any" "type": "array",
"items": {
"type": "object",
"properties": {
"platformId": {
"type": "number",
"example": 2
},
"consented": {
"type": "boolean",
"example": false
}
}
}
} }
} }
} }
@ -1177,10 +1242,26 @@
], ],
"responses": { "responses": {
"200": { "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": { "400": {
"description": "Bad Request" "description": "Invalid request data"
}, },
"403": { "403": {
"description": "Forbidden" "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": { "/api/admin/cleanup/preview": {
"get": { "get": {
"tags": [ "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": { "/api/admin/{groupId}/reorder": {
"put": { "put": {
"tags": [ "tags": [

View File

@ -26,5 +26,9 @@ module.exports = {
// Run tests serially to avoid DB conflicts // Run tests serially to avoid DB conflicts
maxWorkers: 1, maxWorkers: 1,
// Force exit after tests complete // 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", "name": "backend",
"version": "1.0.0", "version": "2.0.1",
"description": "", "description": "",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@ -31,6 +31,7 @@
"find-remove": "^2.0.3", "find-remove": "^2.0.3",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"sharp": "^0.34.4", "sharp": "^0.34.4",
"shortid": "^2.2.16", "shortid": "^2.2.16",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",

View File

@ -16,7 +16,7 @@ const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
const doc = { const doc = {
info: { info: {
title: 'Project Image Uploader API', title: 'Project Image Uploader API',
version: '1.0.0', version: '2.0.1',
description: 'Auto-generated OpenAPI spec with correct mount prefixes' description: 'Auto-generated OpenAPI spec with correct mount prefixes'
}, },
host: 'localhost:5001', 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) => { router.get('/cleanup/preview', async (req, res) => {
/* /*
#swagger.tags = ['Admin - Cleanup'] #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) => { router.delete('/groups/:groupId', async (req, res) => {
/* /*
#swagger.tags = ['Admin - Groups Moderation'] #swagger.tags = ['Admin - Groups Moderation']

View File

@ -6,6 +6,10 @@ const UploadGroup = require('../models/uploadGroup');
const groupRepository = require('../repositories/GroupRepository'); const groupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const ImagePreviewService = require('../services/ImagePreviewService'); const ImagePreviewService = require('../services/ImagePreviewService');
const TelegramNotificationService = require('../services/TelegramNotificationService');
// Singleton-Instanz des Telegram Service
const telegramService = new TelegramNotificationService();
const router = Router(); const router = Router();
@ -117,6 +121,12 @@ router.post('/upload/batch', async (req, res) => {
consents = {}; 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) // Validiere Workshop Consent (Pflichtfeld)
if (!consents.workshopConsent) { if (!consents.workshopConsent) {
return res.status(400).json({ 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`); 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 // Erfolgreiche Antwort mit Management-Token
res.json({ res.json({
groupId: group.groupId, groupId: group.groupId,

View File

@ -58,16 +58,37 @@ router.get('/social-media/platforms', async (req, res) => {
// Group Consents // 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) => { 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 { try {
const { groupId } = req.params; const { groupId } = req.params;
const { workshopConsent, socialMediaConsents } = req.body; const { workshopConsent, socialMediaConsents } = req.body;

View File

@ -5,6 +5,10 @@ const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter'); const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
const auditLogMiddleware = require('../middlewares/auditLog'); 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 // Apply middleware to all management routes
router.use(rateLimitMiddleware); router.use(rateLimitMiddleware);
@ -211,6 +215,20 @@ router.put('/:token/consents', async (req, res) => {
[newValue, groupData.groupId] [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({ return res.json({
success: true, success: true,
message: `Workshop consent ${action}d successfully`, 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({ return res.json({
success: true, success: true,
message: `Social media consent ${action}d successfully`, 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)`); 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({ res.json({
success: true, success: true,
message: 'Group and all associated data deleted successfully', 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) => { 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 { try {
const { token } = req.params; const { token } = req.params;
const { imageIds } = req.body; const { imageIds } = req.body;

View File

@ -4,6 +4,10 @@ const path = require('path');
const initiateResources = require('./utils/initiate-resources'); const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager'); const dbManager = require('./database/DatabaseManager');
const SchedulerService = require('./services/SchedulerService'); 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 // Dev: Swagger UI (mount only in non-production) — require lazily
let swaggerUi = null; let swaggerUi = null;
@ -78,8 +82,19 @@ class Server {
console.log(`✅ Server läuft auf Port ${this._port}`); console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`); console.log(`📊 SQLite Datenbank aktiv`);
// Speichere SchedulerService in app für Admin-Endpoints
this._app.set('schedulerService', SchedulerService);
// Starte Scheduler für automatisches Cleanup // Starte Scheduler für automatisches Cleanup
SchedulerService.start(); 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) { } catch (error) {
console.error('💥 Fehler beim Serverstart:', error); console.error('💥 Fehler beim Serverstart:', error);

View File

@ -1,9 +1,11 @@
const cron = require('node-cron'); const cron = require('node-cron');
const GroupCleanupService = require('./GroupCleanupService'); const GroupCleanupService = require('./GroupCleanupService');
const TelegramNotificationService = require('./TelegramNotificationService');
class SchedulerService { class SchedulerService {
constructor() { constructor() {
this.tasks = []; this.tasks = [];
this.telegramService = new TelegramNotificationService();
} }
start() { start() {
@ -30,7 +32,35 @@ class SchedulerService {
this.tasks.push(cleanupTask); 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 // Für Development: Manueller Trigger
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@ -50,6 +80,42 @@ class SchedulerService {
console.log('[Scheduler] Manual cleanup triggered...'); console.log('[Scheduler] Manual cleanup triggered...');
return await GroupCleanupService.performScheduledCleanup(); 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(); 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 for the backend server
PORT=5000 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) # Database settings (if needed in future)
# DB_HOST=localhost # DB_HOST=localhost
# DB_PORT=3306 # DB_PORT=3306

View File

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

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 source code
COPY backend/ . COPY backend/ .
# Copy development environment configuration # Note: Environment variables are set via docker-compose.yml
COPY docker/dev/backend/config/.env ./.env # No .env file needed in the image
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000

View File

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

View File

@ -13,9 +13,9 @@ WORKDIR /app
# Copy package files first to leverage Docker cache for npm install # Copy package files first to leverage Docker cache for npm install
COPY frontend/package*.json ./ 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.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 # Make env.sh executable
RUN chmod +x ./env.sh RUN chmod +x ./env.sh

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment # Add assignment
echo "window._env_ = {" >> ./env-config.js echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file # List of environment variables to export (add more as needed)
# Each line represents key=value pairs ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
while read -r line || [[ -n "$line" ]];
do # Read each environment variable and add to config
# Split env variables by character `=` for varname in $ENV_VARS; do
if printf '%s\n' "$line" | grep -q -e '='; then # Get value from environment using indirect expansion
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//') value="${!varname}"
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
fi fi
done
# 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}
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
echo "}" >> ./env-config.js 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 - backend
environment: environment:
- API_URL=http://backend:5000 - API_URL=http://backend:5000
- CLIENT_URL=http://localhost - PUBLIC_HOST=public.test.local
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de - INTERNAL_HOST=internal.test.local
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
networks: networks:
- npm-nw - npm-nw
@ -43,13 +42,18 @@ services:
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen) # ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
- ADMIN_SESSION_COOKIE_SECURE=true - ADMIN_SESSION_COOKIE_SECURE=true
# Host Configuration (Public/Internal Separation) # Host Configuration (Public/Internal Separation)
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de - PUBLIC_HOST=public.test.local
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de - INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true - ENABLE_HOST_RESTRICTION=true
- PUBLIC_UPLOAD_RATE_LIMIT=20 - PUBLIC_UPLOAD_RATE_LIMIT=20
- PUBLIC_UPLOAD_RATE_WINDOW=3600000 - PUBLIC_UPLOAD_RATE_WINDOW=3600000
# Trust nginx-proxy-manager (1 hop) # Trust nginx-proxy-manager (1 hop)
- TRUST_PROXY_HOPS=1 - 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 # Default port exposure
EXPOSE 80 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 WORKDIR /usr/share/nginx/html
COPY docker/prod/frontend/config/env.sh ./env.sh 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 # Add bash
RUN apk add --no-cache bash RUN apk add --no-cache bash

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment # Add assignment
echo "window._env_ = {" >> ./env-config.js echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file # List of environment variables to export (add more as needed)
# Each line represents key=value pairs ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
while read -r line || [[ -n "$line" ]];
do # Read each environment variable and add to config
# Split env variables by character `=` for varname in $ENV_VARS; do
if printf '%s\n' "$line" | grep -q -e '='; then # Get value from environment using indirect expansion
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//') value="${!varname}"
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
fi fi
done
# 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}
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
echo "}" >> ./env-config.js 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", "name": "frontend",
"version": "1.1.0", "version": "2.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.1.0", "version": "2.0.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.1.0", "version": "2.0.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -31,7 +31,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "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", "proxy": "http://backend-dev:5000",
"eslintConfig": { "eslintConfig": {

View File

@ -18,8 +18,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" /> <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
<link rel="preconnect" href="https://fonts.gstatic.com"> <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=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&display=swap" rel="stylesheet">
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css"> <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"> <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 */ /* 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 */ /* Page-specific styles for GroupsOverviewPage */
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; } .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-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-title { font-family: 'Open Sans', sans-serif; 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-subtitle { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #666666; margin-bottom: 20px; }
@media (max-width:800px) { .nav__links, .cta { display:none; } } @media (max-width:800px) { .nav__links, .cta { display:none; } }
/* Page-specific styles for ModerationPage */ /* Page-specific styles for ModerationPage */
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; } .moderation-page { font-family: 'Open Sans', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; } 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-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
.moderation-error { color:#dc3545; } .moderation-error { color:#dc3545; }
@ -50,7 +240,7 @@
} }
/* Buttons */ /* 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 { background:#6c757d; color:white; }
.btn-secondary:hover { background:#5a6268; } .btn-secondary:hover { background:#5a6268; }
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; } .btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
@ -61,7 +251,6 @@
.btn-warning:hover { background:#e0a800; } .btn-warning:hover { background:#e0a800; }
.btn-danger { background:#dc3545; color:white; } .btn-danger { background:#dc3545; color:white; }
.btn-danger:hover { background:#c82333; } .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; } .btn:disabled { opacity:0.65; cursor:not-allowed; }
/* Modal */ /* 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-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
.admin-auth-form { width:100%; } .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; } .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 { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx'; import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
import { getHostConfig } from './Utils/hostDetection.js'; import { getHostConfig } from './Utils/hostDetection.js';
import ErrorBoundary from './Components/ComponentUtils/ErrorBoundary.js';
// Always loaded (public + internal) // Always loaded (public + internal)
import MultiUploadPage from './Components/Pages/MultiUploadPage'; import MultiUploadPage from './Components/Pages/MultiUploadPage';
import ManagementPortalPage from './Components/Pages/ManagementPortalPage'; 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 // Lazy loaded (internal only) - Code Splitting für Performance
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage')); const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
@ -18,14 +19,14 @@ const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/Moderati
/** /**
* Protected Route Component * Protected Route Component
* Redirects to upload page if accessed from public host * Shows 403 page if accessed from public host
*/ */
const ProtectedRoute = ({ children }) => { const ProtectedRoute = ({ children }) => {
const hostConfig = getHostConfig(); const hostConfig = getHostConfig();
if (hostConfig.isPublic) { if (hostConfig.isPublic) {
// Redirect to upload page - feature not available on public // Show 403 - feature not available on public
return <Navigate to="/" replace />; return <ErrorPage errorCode="403" />;
} }
return children; return children;
@ -52,66 +53,71 @@ function App() {
const hostConfig = getHostConfig(); const hostConfig = getHostConfig();
return ( return (
<AdminSessionProvider> <ErrorBoundary>
<Router> <AdminSessionProvider>
<Suspense fallback={<LoadingFallback />}> <Router>
<Routes> <Suspense fallback={<LoadingFallback />}>
{/* Public Routes - immer verfügbar */} <Routes>
<Route path="/" element={<MultiUploadPage />} /> {/* Public Routes - immer verfügbar */}
<Route path="/manage/:token" element={<ManagementPortalPage />} /> <Route path="/" element={<MultiUploadPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} />
{/* Internal Only Routes - nur auf internal host geladen */} {/* Error Pages */}
{hostConfig.isInternal && ( <Route path="/error/403" element={<ErrorPage errorCode="403" />} />
<> <Route path="/error/404" element={<ErrorPage errorCode="404" />} />
<Route <Route path="/error/500" element={<ErrorPage errorCode="500" />} />
path="/slideshow" <Route path="/error/502" element={<ErrorPage errorCode="502" />} />
element={ <Route path="/error/503" element={<ErrorPage errorCode="503" />} />
<ProtectedRoute>
<SlideshowPage /> {/* Internal Only Routes - geschützt durch ProtectedRoute */}
</ProtectedRoute> <Route
} path="/slideshow"
/> element={
<Route <ProtectedRoute>
path="/groups/:groupId" <SlideshowPage />
element={ </ProtectedRoute>
<ProtectedRoute> }
<PublicGroupImagesPage /> />
</ProtectedRoute> <Route
} path="/groups/:groupId"
/> element={
<Route <ProtectedRoute>
path="/groups" <PublicGroupImagesPage />
element={ </ProtectedRoute>
<ProtectedRoute> }
<GroupsOverviewPage /> />
</ProtectedRoute> <Route
} path="/groups"
/> element={
<Route <ProtectedRoute>
path="/moderation" <GroupsOverviewPage />
element={ </ProtectedRoute>
<ProtectedRoute> }
<ModerationGroupsPage /> />
</ProtectedRoute> <Route
} path="/moderation"
/> element={
<Route <ProtectedRoute>
path="/moderation/groups/:groupId" <ModerationGroupsPage />
element={ </ProtectedRoute>
<ProtectedRoute> }
<ModerationGroupImagesPage /> />
</ProtectedRoute> <Route
} path="/moderation/groups/:groupId"
/> element={
</> <ProtectedRoute>
)} <ModerationGroupImagesPage />
</ProtectedRoute>
}
/>
{/* 404 / Not Found */} {/* 404 / Not Found */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<ErrorPage errorCode="404" />} />
</Routes> </Routes>
</Suspense> </Suspense>
</Router> </Router>
</AdminSessionProvider> </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 React, { useState } from 'react';
import { Box, Alert, Typography } from '@mui/material'; import { Box, Alert, Typography } from '@mui/material';
import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes'; import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes';
import { apiFetch } from '../../Utils/apiFetch';
/** /**
* Manages consents with save functionality * Manages consents with save functionality
@ -148,7 +149,7 @@ function ConsentManager({
// Save each change // Save each change
for (const change of changes) { for (const change of changes) {
const res = await fetch(`/api/manage/${token}/consents`, { const res = await apiFetch(`/api/manage/${token}/consents`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change) body: JSON.stringify(change)
@ -235,11 +236,11 @@ function ConsentManager({
{/* Email Hint after successful save */} {/* Email Hint after successful save */}
{showEmailHint && successMessage && ( {showEmailHint && successMessage && (
<Alert severity="info" sx={{ mt: 2 }}> <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' }}> <a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
info@hobbyhimmel.de info@hobbyhimmel.de
</a>{' '} </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> </Alert>
)} )}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { Button } from '@mui/material';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../../Utils/apiFetch';
/** /**
* Delete group button with confirmation dialog * Delete group button with confirmation dialog
@ -41,7 +42,7 @@ function DeleteGroupButton({ token, groupName = 'diese Gruppe' }) {
try { try {
setDeleting(true); setDeleting(true);
const res = await fetch(`/api/manage/${token}`, { const res = await apiFetch(`/api/manage/${token}`, {
method: 'DELETE' 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 React from 'react'
import packageJson from '../../../package.json'
import './Css/Footer.css' import './Css/Footer.css'
function Footer() { function Footer() {
const version = window._env_?.APP_VERSION || '1.1.0'; const version = packageJson.version;
return ( return (
<footer> <footer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,15 +77,13 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
const dropzoneTextSx = { const dropzoneTextSx = {
fontSize: '18px', fontSize: '18px',
fontFamily: 'roboto',
color: '#666666', color: '#666666',
margin: '10px 0' margin: '10px 0'
}; };
const dropzoneSubtextSx = { const dropzoneSubtextSx = {
fontSize: '14px', fontSize: '14px',
color: '#999999', color: '#999999'
fontFamily: 'roboto'
}; };
const fileCountSx = { const fileCountSx = {
@ -106,7 +104,7 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
onClick={handleClick} onClick={handleClick}
> >
<Typography sx={dropzoneTextSx}> <Typography sx={dropzoneTextSx}>
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen Mehrere Bilder hier hinziehen oder klicken zum Auswählen
</Typography> </Typography>
<Typography sx={dropzoneSubtextSx}> <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 { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import {
Container,
Card,
Typography,
Box,
CircularProgress
} from '@mui/material';
@ -63,14 +56,14 @@ function GroupsOverviewPage() {
return ( return (
<div className="allContainer"> <div className="allContainer">
<Navbar /> <Navbar />
<Container maxWidth="lg" className="page-container"> <div className="container">
<div className="loading-container"> <div className="flex-center" style={{ minHeight: '400px' }}>
<CircularProgress size={60} color="primary" /> <div className="text-center">
<Typography variant="h6" style={{ marginTop: '20px', color: '#666666' }}> <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>
Slideshows werden geladen... <p className="mt-3" style={{ color: '#666666' }}>Slideshows werden geladen...</p>
</Typography> </div>
</div> </div>
</Container> </div>
<Footer /> <Footer />
</div> </div>
); );
@ -86,53 +79,39 @@ function GroupsOverviewPage() {
</Helmet> </Helmet>
<Navbar /> <Navbar />
<Container maxWidth="lg" className="page-container"> <div className="container page-container">
{/* Header */} {/* Header */}
<Card className="header-card"> <div className="card">
<Typography className="header-title"> <h1 className="page-title">Alle Slideshows</h1>
Alle Slideshows <p className="page-subtitle">Übersicht aller erstellten Slideshows.</p>
</Typography> </div>
<Typography className="header-subtitle">
Übersicht aller erstellten Slideshows.
</Typography>
</Card>
{/* Groups Grid */} {/* Groups Grid */}
{error ? ( {error ? (
<div className="empty-state"> <div className="empty-state">
<Typography variant="h6" style={{ color: '#f44336', marginBottom: '20px' }}> <h2 style={{ color: '#f44336' }} className="mb-3">😕 Fehler beim Laden</h2>
😕 Fehler beim Laden <p style={{ color: '#666666' }} className="mb-4">{error}</p>
</Typography>
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
{error}
</Typography>
<button onClick={loadGroups} className="btn btn-secondary"> <button onClick={loadGroups} className="btn btn-secondary">
🔄 Erneut versuchen 🔄 Erneut versuchen
</button> </button>
</div> </div>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<Typography variant="h4" style={{ color: '#666666', marginBottom: '20px' }}> <h2 style={{ color: '#666666' }} className="mb-3">📸 Keine Slideshows vorhanden</h2>
📸 Keine Slideshows vorhanden <p style={{ color: '#999999' }} className="mb-4">
</Typography>
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen. Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
</Typography> </p>
<button <button className="btn btn-success" onClick={handleCreateNew}>
className="btn btn-success"
onClick={handleCreateNew}
style={{ fontSize: '16px', padding: '12px 24px' }}
>
Erste Slideshow erstellen Erste Slideshow erstellen
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<Box marginBottom={2}> <div className="mb-3">
<Typography variant="h6" style={{ color: '#666666' }}> <h3 style={{ color: '#666666' }}>
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
</Typography> </h3>
</Box> </div>
<ImageGallery <ImageGallery
items={groups} items={groups}
onViewImages={(group) => handleViewGroup(group.groupId)} onViewImages={(group) => handleViewGroup(group.groupId)}
@ -142,7 +121,7 @@ function GroupsOverviewPage() {
/> />
</> </>
)} )}
</Container> </div>
<div className="footerContainer"> <div className="footerContainer">
<Footer /> <Footer />

View File

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

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Container, Box } from '@mui/material';
// Services // Services
import { adminGet } from '../../services/adminApi'; import { adminGet, adminRequest } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler'; import { handleAdminError } from '../../services/adminErrorHandler';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx'; import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx'; import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
@ -15,6 +14,9 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor'; import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
import Loading from '../ComponentUtils/LoadingAnimation/Loading'; import Loading from '../ComponentUtils/LoadingAnimation/Loading';
// UI
import Swal from 'sweetalert2';
/** /**
* ModerationGroupImagesPage - Admin page for moderating group images * ModerationGroupImagesPage - Admin page for moderating group images
* *
@ -72,6 +74,35 @@ const ModerationGroupImagesPage = () => {
loadGroup(); loadGroup();
}, [isAuthenticated, 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 = () => { const renderContent = () => {
if (loading) return <Loading />; if (loading) return <Loading />;
if (error) return <div className="moderation-error">{error}</div>; if (error) return <div className="moderation-error">{error}</div>;
@ -81,13 +112,15 @@ const ModerationGroupImagesPage = () => {
<div className="allContainer"> <div className="allContainer">
<Navbar /> <Navbar />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}> <div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
{/* Image Descriptions Manager */} {/* Image Descriptions Manager */}
<ImageDescriptionManager <ImageDescriptionManager
images={group.images} images={group.images}
groupId={groupId} groupId={groupId}
onRefresh={loadGroup} onRefresh={loadGroup}
mode="moderate" mode="moderate"
enableReordering={true}
onReorder={handleReorder}
/> />
{/* Group Metadata Editor */} {/* Group Metadata Editor */}
@ -99,15 +132,15 @@ const ModerationGroupImagesPage = () => {
/> />
{/* Back Button */} {/* Back Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}> <div className="flex-center mt-4">
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate('/moderation')} onClick={() => navigate('/moderation')}
> >
Zurück zur Übersicht Zurück zur Übersicht
</button> </button>
</Box> </div>
</Container> </div>
<div className="footerContainer"><Footer /></div> <div className="footerContainer"><Footer /></div>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -1,11 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import {
Typography,
Box,
CircularProgress,
IconButton
} from '@mui/material';
import { import {
Home as HomeIcon, Home as HomeIcon,
ExitToApp as ExitIcon ExitToApp as ExitIcon
@ -172,12 +166,12 @@ function SlideshowPage() {
if (loading) { if (loading) {
return ( 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' }}>
<Box sx={loadingContainerSx}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<CircularProgress sx={{ color: 'white', mb: 2 }} /> <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>
<Typography sx={{ color: 'white' }}>Slideshow wird geladen...</Typography> <p style={{ color: 'white', margin: 0 }}>Slideshow wird geladen...</p>
</Box> </div>
</Box> </div>
); );
} }
@ -192,27 +186,27 @@ function SlideshowPage() {
if (error) { if (error) {
return ( 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' }}>
<Box sx={loadingContainerSx}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>{error}</Typography> <p style={{ color: 'white', fontSize: '24px', margin: 0 }}>{error}</p>
<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">
<HomeIcon /> <HomeIcon />
</IconButton> </button>
</Box> </div>
</Box> </div>
); );
} }
if (!currentGroup || !currentImage) { if (!currentGroup || !currentImage) {
return ( 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' }}>
<Box sx={loadingContainerSx}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>Keine Bilder verfügbar</Typography> <p style={{ color: 'white', fontSize: '24px', margin: 0 }}>Keine Bilder verfügbar</p>
<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">
<HomeIcon /> <HomeIcon />
</IconButton> </button>
</Box> </div>
</Box> </div>
); );
} }
@ -275,41 +269,41 @@ function SlideshowPage() {
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' }; const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
return ( 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 */} {/* 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 /> <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 /> <ExitIcon />
</IconButton> </button>
{/* Hauptbild */} {/* 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) */} {/* Bildbeschreibung (wenn vorhanden) */}
{currentImage.imageDescription && ( {currentImage.imageDescription && (
<Box sx={imageDescriptionSx}> <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 }}>
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography> <p style={{ color: 'white', fontSize: '18px', margin: 0, lineHeight: 1.4, fontFamily: 'Open Sans, sans-serif' }}>{currentImage.imageDescription}</p>
</Box> </div>
)} )}
{/* Beschreibung */} {/* 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 */} {/* 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 */} {/* 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) */} {/* 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 */} {/* Meta-Informationen */}
<Typography sx={metaTextSx}>Bild {currentImageIndex + 1} von {currentGroup.images.length} Slideshow {currentGroupIndex + 1} von {allGroups.length}</Typography> <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>
</Box> </div>
</Box> </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 // Batch-Upload Funktion für mehrere Bilder
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => { export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
if (!images || images.length === 0) { if (!images || images.length === 0) {
@ -29,7 +31,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
} }
try { try {
const response = await fetch('/api/upload/batch', { const response = await apiFetch('/api/upload/batch', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@ -50,7 +52,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
// Einzelne Gruppe abrufen // Einzelne Gruppe abrufen
export const fetchGroup = async (groupId) => { export const fetchGroup = async (groupId) => {
try { try {
const response = await fetch(`/api/groups/${groupId}`); const response = await apiFetch(`/api/groups/${groupId}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@ -67,7 +69,7 @@ export const fetchGroup = async (groupId) => {
// Alle Gruppen abrufen // Alle Gruppen abrufen
export const fetchAllGroups = async () => { export const fetchAllGroups = async () => {
try { try {
const response = await fetch('/api/groups'); const response = await apiFetch('/api/groups');
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@ -84,7 +86,7 @@ export const fetchAllGroups = async () => {
// Gruppe löschen // Gruppe löschen
export const deleteGroup = async (groupId) => { export const deleteGroup = async (groupId) => {
try { try {
const response = await fetch(`/api/groups/${groupId}`, { const response = await apiFetch(`/api/groups/${groupId}`, {
method: 'DELETE' method: 'DELETE'
}); });

View File

@ -1,4 +1,4 @@
import axios from 'axios' import apiClient from './apiClient'
//import swal from 'sweetalert'; //import swal from 'sweetalert';
import Swal from 'sweetalert2/dist/sweetalert2.js' import Swal from 'sweetalert2/dist/sweetalert2.js'
@ -22,7 +22,7 @@ export async function sendRequest(file, handleLoading, handleResponse) {
handleLoading() handleLoading()
try { try {
const res = await axios.post(window._env_.API_URL + '/upload', formData, { const res = await apiClient.post('/upload', formData, {
headers: { headers: {
"Content-Type": "multipart/form-data" "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) 3)
echo -e "${GREEN}Pushe Production Images zur Registry...${NC}" 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) 4)
echo -e "${GREEN}Baue Container neu...${NC}" 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) ## 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]+)') 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]+)') 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): def ensure_entry(text, *, pattern, value, anchor_line, expected_line, label):
match = pattern.search(text) 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) print('ERROR: Failed to ensure ADMIN_SESSION_SECRET uses environment variable in docker-compose.yml', file=sys.stderr)
sys.exit(4) 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: if changed:
path.write_text(new_text) path.write_text(new_text)
print('UPDATED') 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>