Compare commits
7 Commits
920a81e075
...
8818d2987d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8818d2987d | |||
| 40aa546498 | |||
| e4712f9e7e | |||
| e4a76a6b3d | |||
| 91d6d06687 | |||
| 215acaa67f | |||
| 25dda32c4e |
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,5 +1,20 @@
|
|||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
## [Unreleased] - Branch: feature/public-internal-hosts
|
||||
|
||||
### 🌐 Public/Internal Host Separation (November 25, 2025)
|
||||
|
|
|
|||
|
|
@ -593,6 +593,74 @@ Für Production mit echten Subdomains siehe:
|
|||
|
||||
---
|
||||
|
||||
## 🚀 Release Management
|
||||
|
||||
### Automated Release (EMPFOHLEN)
|
||||
|
||||
**Ein Befehl macht alles:**
|
||||
|
||||
```bash
|
||||
npm run release # Patch: 1.2.0 → 1.2.1
|
||||
npm run release:minor # Minor: 1.2.0 → 1.3.0
|
||||
npm run release:major # Major: 1.2.0 → 2.0.0
|
||||
```
|
||||
|
||||
**Was passiert automatisch:**
|
||||
1. ✅ Version in allen package.json erhöht
|
||||
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
|
||||
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
|
||||
4. ✅ Git Commit erstellt
|
||||
5. ✅ Git Tag erstellt
|
||||
6. ✅ Preview anzeigen + Bestätigung
|
||||
|
||||
Dann nur noch:
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
### Beispiel-Workflow:
|
||||
|
||||
```bash
|
||||
# Features entwickeln mit Conventional Commits:
|
||||
git commit -m "feat: Add user login"
|
||||
git commit -m "fix: Fix button alignment"
|
||||
git commit -m "refactor: Extract ConsentFilter component"
|
||||
|
||||
# Release erstellen:
|
||||
npm run release:minor
|
||||
|
||||
# Preview wird angezeigt, dann [Y] drücken
|
||||
# Push:
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
### CHANGELOG wird automatisch generiert!
|
||||
|
||||
Das Release-Script (`scripts/release.sh`) gruppiert deine Commits nach Typ:
|
||||
- `feat:` → ✨ Features
|
||||
- `fix:` → 🐛 Fixes
|
||||
- `refactor:` → ♻️ Refactoring
|
||||
- `chore:` → 🔧 Chores
|
||||
- `docs:` → 📚 Documentation
|
||||
|
||||
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
|
||||
|
||||
### Manuelle Scripts (falls nötig)
|
||||
|
||||
```bash
|
||||
# Version nur synchronisieren (ohne Bump):
|
||||
./scripts/sync-version.sh
|
||||
|
||||
# Version manuell bumpen:
|
||||
./scripts/bump-version.sh patch # oder minor/major
|
||||
```
|
||||
|
||||
**Version-Synchronisation:**
|
||||
- Single Source of Truth: `frontend/package.json`
|
||||
- Wird synchronisiert zu: `backend/package.json`, `Footer.js`, `generate-openapi.js`, Docker Images
|
||||
|
||||
---
|
||||
|
||||
## Nützliche Befehle
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Project Image Uploader API",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Auto-generated OpenAPI spec with correct mount prefixes"
|
||||
},
|
||||
"servers": [
|
||||
|
|
@ -2573,6 +2573,96 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/groups/{groupId}/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin - Groups Moderation"
|
||||
],
|
||||
"summary": "Reorder images in a group",
|
||||
"description": "Updates the display order of images within a group",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "groupId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"description": "Group ID",
|
||||
"example": "abc123def456"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Images reordered successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Image order updated successfully"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updatedImages": {
|
||||
"type": "number",
|
||||
"example": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"xml": {
|
||||
"name": "main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid imageIds parameter"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Group not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"imageIds"
|
||||
],
|
||||
"properties": {
|
||||
"imageIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"example": [
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
4
|
||||
],
|
||||
"description": "Array of image IDs in new order"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/{groupId}/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
|
|||
const doc = {
|
||||
info: {
|
||||
title: 'Project Image Uploader API',
|
||||
version: '1.0.0',
|
||||
version: '1.10.0',
|
||||
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
|
||||
},
|
||||
host: 'localhost:5001',
|
||||
|
|
|
|||
|
|
@ -978,6 +978,120 @@ router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.put('/groups/:groupId/reorder', async (req, res) => {
|
||||
/*
|
||||
#swagger.tags = ['Admin - Groups Moderation']
|
||||
#swagger.summary = 'Reorder images in a group'
|
||||
#swagger.description = 'Updates the display order of images within a group'
|
||||
#swagger.parameters['groupId'] = {
|
||||
in: 'path',
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'Group ID',
|
||||
example: 'abc123def456'
|
||||
}
|
||||
#swagger.requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['imageIds'],
|
||||
properties: {
|
||||
imageIds: {
|
||||
type: 'array',
|
||||
items: { type: 'integer' },
|
||||
example: [5, 3, 1, 2, 4],
|
||||
description: 'Array of image IDs in new order'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#swagger.responses[200] = {
|
||||
description: 'Images reordered successfully',
|
||||
schema: {
|
||||
success: true,
|
||||
message: 'Image order updated successfully',
|
||||
data: {
|
||||
updatedImages: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
#swagger.responses[400] = {
|
||||
description: 'Invalid imageIds parameter'
|
||||
}
|
||||
#swagger.responses[404] = {
|
||||
description: 'Group not found'
|
||||
}
|
||||
*/
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const { imageIds } = req.body;
|
||||
|
||||
// Validate imageIds
|
||||
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'imageIds array is required and cannot be empty'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that all imageIds are numbers
|
||||
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
|
||||
if (invalidIds.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
|
||||
});
|
||||
}
|
||||
|
||||
// Verify group exists
|
||||
const groupData = await GroupRepository.getGroupById(groupId);
|
||||
if (!groupData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Group not found',
|
||||
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
|
||||
});
|
||||
}
|
||||
|
||||
// Execute reorder using GroupRepository
|
||||
const result = await GroupRepository.updateImageOrder(groupId, imageIds);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Image order updated successfully',
|
||||
data: result
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[ADMIN] Error reordering images for group ${req.params.groupId}:`, error.message);
|
||||
|
||||
// Handle specific errors
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Group or images not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('mismatch')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to reorder images',
|
||||
message: 'Fehler beim Sortieren der Bilder'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/groups/:groupId', async (req, res) => {
|
||||
/*
|
||||
#swagger.tags = ['Admin - Groups Moderation']
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ services:
|
|||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- API_URL=http://localhost:5001
|
||||
- CLIENT_URL=http://localhost:3000
|
||||
- PUBLIC_HOST=public.test.local
|
||||
- INTERNAL_HOST=internal.test.local
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ services:
|
|||
- backend
|
||||
environment:
|
||||
- API_URL=http://backend:5000
|
||||
- CLIENT_URL=http://localhost
|
||||
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de
|
||||
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
|
||||
|
||||
|
|
|
|||
149
frontend/ERROR_HANDLING.md
Normal file
149
frontend/ERROR_HANDLING.md
Normal 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
|
||||
```
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -31,7 +31,8 @@
|
|||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"eject": "react-scripts eject",
|
||||
"version": "cd .. && ./scripts/sync-version.sh && git add -A"
|
||||
},
|
||||
"proxy": "http://backend-dev:5000",
|
||||
"eslintConfig": {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,205 @@
|
|||
/* Main shared styles for cards, buttons, modals used across pages */
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY - Zentrale Schrift-Definitionen
|
||||
============================================ */
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1, .h1 {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 28px;
|
||||
color: #333333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2, .h2 {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
color: #333333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h3, .h3 {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
color: #333333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p, .text-body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.text-subtitle {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
/* ============================================
|
||||
LAYOUT & CONTAINERS
|
||||
============================================ */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PAGE HEADERS
|
||||
============================================ */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 28px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.text-center-block {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.mt-1 { margin-top: 8px; }
|
||||
.mt-2 { margin-top: 16px; }
|
||||
.mt-3 { margin-top: 24px; }
|
||||
.mt-4 { margin-top: 32px; }
|
||||
.mb-1 { margin-bottom: 8px; }
|
||||
.mb-2 { margin-bottom: 16px; }
|
||||
.mb-3 { margin-bottom: 24px; }
|
||||
.mb-4 { margin-bottom: 32px; }
|
||||
.p-2 { padding: 16px; }
|
||||
.p-3 { padding: 24px; }
|
||||
|
||||
/* ============================================
|
||||
SUCCESS BOX (Upload Success)
|
||||
============================================ */
|
||||
.success-box {
|
||||
margin-top: 32px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px rgba(76, 175, 80, 0.4);
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.success-box h2 {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.success-box p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-box-highlight {
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXISTING STYLES BELOW
|
||||
============================================ */
|
||||
|
||||
/* Page-specific styles for GroupsOverviewPage */
|
||||
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
|
||||
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
|
||||
.header-title { font-family: roboto; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
||||
.header-subtitle { font-family: roboto; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
||||
.header-title { font-family: 'Open Sans', sans-serif; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
||||
.header-subtitle { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
||||
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
||||
|
||||
/* Page-specific styles for ModerationPage */
|
||||
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; }
|
||||
.moderation-page { font-family: 'Open Sans', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
||||
p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
|
||||
.moderation-content h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
||||
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
||||
.moderation-error { color:#dc3545; }
|
||||
|
||||
|
|
@ -50,7 +240,7 @@
|
|||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:0.85rem; transition:background-color 0.2s; flex:1; min-width:80px; }
|
||||
.btn { padding:12px 30px; border:none; border-radius:6px; cursor:pointer; font-size:16px; transition:background-color 0.2s; min-width:80px; }
|
||||
.btn-secondary { background:#6c757d; color:white; }
|
||||
.btn-secondary:hover { background:#5a6268; }
|
||||
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
|
||||
|
|
@ -61,7 +251,6 @@
|
|||
.btn-warning:hover { background:#e0a800; }
|
||||
.btn-danger { background:#dc3545; color:white; }
|
||||
.btn-danger:hover { background:#c82333; }
|
||||
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
|
||||
.btn:disabled { opacity:0.65; cursor:not-allowed; }
|
||||
|
||||
/* Modal */
|
||||
|
|
@ -102,3 +291,32 @@
|
|||
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
|
||||
.admin-auth-form { width:100%; }
|
||||
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }
|
||||
|
||||
/* ============================================
|
||||
MATERIAL-UI OVERRIDES - Globale Schriftart
|
||||
============================================ */
|
||||
/* TextField, Input, Textarea */
|
||||
.MuiTextField-root input,
|
||||
.MuiTextField-root textarea,
|
||||
.MuiInputBase-root,
|
||||
.MuiInputBase-input,
|
||||
.MuiOutlinedInput-input {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.MuiFormLabel-root,
|
||||
.MuiInputLabel-root,
|
||||
.MuiTypography-root {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.MuiButton-root {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Checkbox Labels */
|
||||
.MuiFormControlLabel-label {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import './App.css';
|
|||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
|
||||
import { getHostConfig } from './Utils/hostDetection.js';
|
||||
import ErrorBoundary from './Components/ComponentUtils/ErrorBoundary.js';
|
||||
|
||||
// Always loaded (public + internal)
|
||||
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
||||
import ManagementPortalPage from './Components/Pages/ManagementPortalPage';
|
||||
import NotFoundPage from './Components/Pages/404Page.js';
|
||||
import ErrorPage from './Components/Pages/ErrorPage.js';
|
||||
|
||||
// Lazy loaded (internal only) - Code Splitting für Performance
|
||||
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
|
||||
|
|
@ -18,14 +19,14 @@ const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/Moderati
|
|||
|
||||
/**
|
||||
* Protected Route Component
|
||||
* Redirects to upload page if accessed from public host
|
||||
* Shows 403 page if accessed from public host
|
||||
*/
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const hostConfig = getHostConfig();
|
||||
|
||||
if (hostConfig.isPublic) {
|
||||
// Redirect to upload page - feature not available on public
|
||||
return <Navigate to="/" replace />;
|
||||
// Show 403 - feature not available on public
|
||||
return <ErrorPage errorCode="403" />;
|
||||
}
|
||||
|
||||
return children;
|
||||
|
|
@ -52,66 +53,71 @@ function App() {
|
|||
const hostConfig = getHostConfig();
|
||||
|
||||
return (
|
||||
<AdminSessionProvider>
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Public Routes - immer verfügbar */}
|
||||
<Route path="/" element={<MultiUploadPage />} />
|
||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||
<ErrorBoundary>
|
||||
<AdminSessionProvider>
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Public Routes - immer verfügbar */}
|
||||
<Route path="/" element={<MultiUploadPage />} />
|
||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||
|
||||
{/* Internal Only Routes - nur auf internal host geladen */}
|
||||
{hostConfig.isInternal && (
|
||||
<>
|
||||
<Route
|
||||
path="/slideshow"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SlideshowPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/groups/:groupId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PublicGroupImagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/groups"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GroupsOverviewPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ModerationGroupsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/groups/:groupId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ModerationGroupImagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Error Pages */}
|
||||
<Route path="/error/403" element={<ErrorPage errorCode="403" />} />
|
||||
<Route path="/error/404" element={<ErrorPage errorCode="404" />} />
|
||||
<Route path="/error/500" element={<ErrorPage errorCode="500" />} />
|
||||
<Route path="/error/502" element={<ErrorPage errorCode="502" />} />
|
||||
<Route path="/error/503" element={<ErrorPage errorCode="503" />} />
|
||||
|
||||
{/* Internal Only Routes - geschützt durch ProtectedRoute */}
|
||||
<Route
|
||||
path="/slideshow"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SlideshowPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/groups/:groupId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PublicGroupImagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/groups"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GroupsOverviewPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ModerationGroupsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/groups/:groupId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ModerationGroupImagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 404 / Not Found */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<ErrorPage errorCode="404" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</AdminSessionProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Alert, Typography } from '@mui/material';
|
||||
import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
/**
|
||||
* Manages consents with save functionality
|
||||
|
|
@ -148,7 +149,7 @@ function ConsentManager({
|
|||
|
||||
// Save each change
|
||||
for (const change of changes) {
|
||||
const res = await fetch(`/api/manage/${token}/consents`, {
|
||||
const res = await apiFetch(`/api/manage/${token}/consents`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(change)
|
||||
|
|
@ -235,11 +236,11 @@ function ConsentManager({
|
|||
{/* Email Hint after successful save */}
|
||||
{showEmailHint && successMessage && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<strong>Wichtig:</strong> Bitte senden Sie jetzt eine E-Mail an{' '}
|
||||
<strong>Wichtig:</strong> Bitte sende eine E-Mail an{' '}
|
||||
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
|
||||
info@hobbyhimmel.de
|
||||
</a>{' '}
|
||||
mit Ihrer Gruppen-ID, um die Löschung Ihrer Bilder auf den Social Media Plattformen anzufordern.
|
||||
mit Deiner Gruppen-ID, um die Löschung Deiner Bilder auf den Social Media Plattformen anzufordern.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #808080;
|
||||
font-family: "Roboto", sans-serif;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-weight: lighter;
|
||||
margin: 0;
|
||||
padding-right: 20px;
|
||||
|
|
@ -23,7 +23,7 @@ footer {
|
|||
footer a {
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
font-family: "Roboto", sans-serif;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-weight: lighter;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -111,7 +111,9 @@
|
|||
background: #f8f9fa;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ImageGalleryCard - No preview state */
|
||||
|
|
@ -185,7 +187,7 @@
|
|||
|
||||
.image-gallery-title {
|
||||
margin-bottom: 15px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
|
|
@ -237,13 +239,18 @@
|
|||
background: rgba(0,0,0,0.7);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
opacity: 1; /* Always visible on mobile */
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
touch-action: none; /* Prevent scrolling when touching handle */
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background: rgba(0,0,0,0.9);
|
||||
}
|
||||
|
||||
.image-gallery-card.reorderable:hover .drag-handle {
|
||||
|
|
@ -294,7 +301,7 @@
|
|||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
min-height: 50px;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ header {
|
|||
.logo {
|
||||
margin-right: auto;
|
||||
color: #ECF0F1;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -33,7 +33,7 @@ header {
|
|||
.nav__links a,
|
||||
.cta,
|
||||
.overlay__content a {
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-weight: 500;
|
||||
color: #edf0f1;
|
||||
text-decoration: none;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Button } from '@mui/material';
|
|||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import Swal from 'sweetalert2';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
/**
|
||||
* Delete group button with confirmation dialog
|
||||
|
|
@ -41,7 +42,7 @@ function DeleteGroupButton({ token, groupName = 'diese Gruppe' }) {
|
|||
try {
|
||||
setDeleting(true);
|
||||
|
||||
const res = await fetch(`/api/manage/${token}`, {
|
||||
const res = await apiFetch(`/api/manage/${token}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
38
frontend/src/Components/ComponentUtils/ErrorBoundary.js
Normal file
38
frontend/src/Components/ComponentUtils/ErrorBoundary.js
Normal 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;
|
||||
|
|
@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
|
|||
import DescriptionInput from './MultiUpload/DescriptionInput';
|
||||
import { adminRequest } from '../../services/adminApi';
|
||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
/**
|
||||
* Manages group metadata with save functionality
|
||||
|
|
@ -76,7 +77,7 @@ function GroupMetadataEditor({
|
|||
if (isModerateMode) {
|
||||
await adminRequest(endpoint, method, metadata);
|
||||
} else {
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(metadata)
|
||||
|
|
@ -143,7 +144,7 @@ function GroupMetadataEditor({
|
|||
>
|
||||
{/* Component Header */}
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
📝 Projekt-Informationen
|
||||
Projekt-Informationen
|
||||
</Typography>
|
||||
|
||||
<DescriptionInput
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
|
|||
import ImageGallery from './ImageGallery';
|
||||
import { adminRequest } from '../../services/adminApi';
|
||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
/**
|
||||
* Manages image descriptions with save functionality
|
||||
|
|
@ -49,7 +50,7 @@ function ImageDescriptionManager({
|
|||
if (mode === 'moderate') {
|
||||
await adminRequest(endpoint, 'DELETE');
|
||||
} else {
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ function ImageDescriptionManager({
|
|||
if (mode === 'moderate') {
|
||||
await adminRequest(endpoint, method, { descriptions });
|
||||
} else {
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ descriptions })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core';
|
||||
|
|
@ -34,11 +35,17 @@ const ImageGallery = ({
|
|||
imageDescriptions = {},
|
||||
onDescriptionChange = null
|
||||
}) => {
|
||||
// Sensors for drag and drop (touch-friendly)
|
||||
// Sensors for drag and drop (desktop + mobile optimized)
|
||||
const sensors = useSensors(
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 0, // No delay - allow immediate dragging
|
||||
tolerance: 0, // No tolerance - precise control
|
||||
},
|
||||
}),
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Require 8px movement before drag starts
|
||||
distance: 5, // Require 5px movement before drag starts (desktop)
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
|
|
|
|||
|
|
@ -221,71 +221,30 @@ const ImageGalleryCard = ({
|
|||
mode === 'preview' ? (
|
||||
// Preview mode actions (for upload preview)
|
||||
<>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(itemId)}
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑️ Löschen</button>
|
||||
{!isEditMode ? (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => onEditMode?.(true)}
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => onEditMode?.(true)}>✏️ Edit </button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => onEditMode?.(false)}
|
||||
>
|
||||
✅ Fertig
|
||||
</button>
|
||||
<button className="btn btn-success" onClick={() => onEditMode?.(false)}>✅ Fertig</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Moderation mode actions (for existing groups)
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onViewImages(item)}
|
||||
>
|
||||
✏️ Gruppe editieren
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => onViewImages(item)}>✏️ Gruppe editieren</button>
|
||||
|
||||
{isPending ? (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => onApprove(itemId, true)}
|
||||
>
|
||||
✅ Freigeben
|
||||
</button>
|
||||
<button className="btn btn-success" onClick={() => onApprove(itemId, true)}>✅ Freigeben</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={() => onApprove(itemId, false)}
|
||||
>
|
||||
⏸️ Sperren
|
||||
</button>
|
||||
<button className="btn btn-warning" onClick={() => onApprove(itemId, false)}>⏸️ Sperren</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(itemId)}
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑️ Löschen</button>
|
||||
</>
|
||||
)
|
||||
) : mode !== 'single-image' ? (
|
||||
// Public view mode (only for group cards, not single images)
|
||||
<button
|
||||
className="view-button"
|
||||
onClick={() => onViewImages(item)}
|
||||
title="Anzeigen"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
<button className="view-button" onClick={() => onViewImages(item)} title="Anzeigen">Anzeigen</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ const Loading = () => {
|
|||
<div className="loading-logo-container">
|
||||
<div className="rotor">
|
||||
<svg
|
||||
className="loading-logo"
|
||||
class="loading-logo"
|
||||
version="1.1"
|
||||
viewBox="0 0 841.89 595.28"
|
||||
viewBox="260 90 310 190"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="g136" display="inline">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ function DescriptionInput({
|
|||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const fieldLabelSx = {
|
||||
fontFamily: 'roboto',
|
||||
fontSize: '14px',
|
||||
color: '#555555',
|
||||
marginBottom: '8px',
|
||||
|
|
@ -25,7 +24,6 @@ function DescriptionInput({
|
|||
};
|
||||
|
||||
const sectionTitleSx = {
|
||||
fontFamily: 'roboto',
|
||||
fontSize: '18px',
|
||||
color: '#333333',
|
||||
marginBottom: '15px',
|
||||
|
|
@ -68,7 +66,7 @@ function DescriptionInput({
|
|||
};
|
||||
|
||||
const requiredIndicatorSx = { color: '#E57373', fontSize: '16px' };
|
||||
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px', fontStyle: 'italic' };
|
||||
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px' };
|
||||
|
||||
return (
|
||||
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}>
|
||||
|
|
|
|||
|
|
@ -77,15 +77,13 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
|||
|
||||
const dropzoneTextSx = {
|
||||
fontSize: '18px',
|
||||
fontFamily: 'roboto',
|
||||
color: '#666666',
|
||||
margin: '10px 0'
|
||||
};
|
||||
|
||||
const dropzoneSubtextSx = {
|
||||
fontSize: '14px',
|
||||
color: '#999999',
|
||||
fontFamily: 'roboto'
|
||||
color: '#999999'
|
||||
};
|
||||
|
||||
const fileCountSx = {
|
||||
|
|
@ -106,7 +104,7 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
|||
onClick={handleClick}
|
||||
>
|
||||
<Typography sx={dropzoneTextSx}>
|
||||
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
|
||||
Mehrere Bilder hier hinziehen oder klicken zum Auswählen
|
||||
</Typography>
|
||||
|
||||
<Typography sx={dropzoneSubtextSx}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
9
frontend/src/Components/Pages/Css/ErrorPage.css
Normal file
9
frontend/src/Components/Pages/Css/ErrorPage.css
Normal 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;
|
||||
}
|
||||
179
frontend/src/Components/Pages/Css/ModerationGroupsPage.css
Normal file
179
frontend/src/Components/Pages/Css/ModerationGroupsPage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
56
frontend/src/Components/Pages/ErrorPage.js
Normal file
56
frontend/src/Components/Pages/ErrorPage.js
Normal 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
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
|
||||
|
||||
|
||||
|
|
@ -63,14 +56,14 @@ function GroupsOverviewPage() {
|
|||
return (
|
||||
<div className="allContainer">
|
||||
<Navbar />
|
||||
<Container maxWidth="lg" className="page-container">
|
||||
<div className="loading-container">
|
||||
<CircularProgress size={60} color="primary" />
|
||||
<Typography variant="h6" style={{ marginTop: '20px', color: '#666666' }}>
|
||||
Slideshows werden geladen...
|
||||
</Typography>
|
||||
<div className="container">
|
||||
<div className="flex-center" style={{ minHeight: '400px' }}>
|
||||
<div className="text-center">
|
||||
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid #f3f3f3', borderTop: '4px solid #3498db', borderRadius: '50%', animation: 'spin 1s linear infinite', margin: '0 auto' }}></div>
|
||||
<p className="mt-3" style={{ color: '#666666' }}>Slideshows werden geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -86,53 +79,39 @@ function GroupsOverviewPage() {
|
|||
</Helmet>
|
||||
<Navbar />
|
||||
|
||||
<Container maxWidth="lg" className="page-container">
|
||||
<div className="container page-container">
|
||||
{/* Header */}
|
||||
<Card className="header-card">
|
||||
<Typography className="header-title">
|
||||
Alle Slideshows
|
||||
</Typography>
|
||||
<Typography className="header-subtitle">
|
||||
Übersicht aller erstellten Slideshows.
|
||||
</Typography>
|
||||
</Card>
|
||||
<div className="card">
|
||||
<h1 className="page-title">Alle Slideshows</h1>
|
||||
<p className="page-subtitle">Übersicht aller erstellten Slideshows.</p>
|
||||
</div>
|
||||
|
||||
{/* Groups Grid */}
|
||||
{error ? (
|
||||
<div className="empty-state">
|
||||
<Typography variant="h6" style={{ color: '#f44336', marginBottom: '20px' }}>
|
||||
😕 Fehler beim Laden
|
||||
</Typography>
|
||||
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
<div className="empty-state">
|
||||
<h2 style={{ color: '#f44336' }} className="mb-3">😕 Fehler beim Laden</h2>
|
||||
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
|
||||
<button onClick={loadGroups} className="btn btn-secondary">
|
||||
🔄 Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Typography variant="h4" style={{ color: '#666666', marginBottom: '20px' }}>
|
||||
📸 Keine Slideshows vorhanden
|
||||
</Typography>
|
||||
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
|
||||
<div className="empty-state">
|
||||
<h2 style={{ color: '#666666' }} className="mb-3">📸 Keine Slideshows vorhanden</h2>
|
||||
<p style={{ color: '#999999' }} className="mb-4">
|
||||
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
|
||||
</Typography>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleCreateNew}
|
||||
style={{ fontSize: '16px', padding: '12px 24px' }}
|
||||
>
|
||||
</p>
|
||||
<button className="btn btn-success" onClick={handleCreateNew}>
|
||||
➕ Erste Slideshow erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Box marginBottom={2}>
|
||||
<Typography variant="h6" style={{ color: '#666666' }}>
|
||||
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
||||
</Typography>
|
||||
</Box>
|
||||
<div className="mb-3">
|
||||
<h3 style={{ color: '#666666' }}>
|
||||
{groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
||||
</h3>
|
||||
</div>
|
||||
<ImageGallery
|
||||
items={groups}
|
||||
onViewImages={(group) => handleViewGroup(group.groupId)}
|
||||
|
|
@ -142,7 +121,7 @@ function GroupsOverviewPage() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<div className="footerContainer">
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Container, Card, CardContent, Typography, Box, Button } from '@mui/material';
|
||||
import Swal from 'sweetalert2';
|
||||
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
||||
import Footer from '../ComponentUtils/Footer';
|
||||
|
|
@ -12,6 +11,7 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
|
|||
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
||||
import ConsentManager from '../ComponentUtils/ConsentManager';
|
||||
import DeleteGroupButton from '../ComponentUtils/DeleteGroupButton';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
/**
|
||||
* ManagementPortalPage - Self-service management for uploaded groups
|
||||
|
|
@ -36,7 +36,7 @@ function ManagementPortalPage() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(`/api/manage/${token}`);
|
||||
const res = await apiFetch(`/api/manage/${token}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
setError('Ungültiger oder abgelaufener Verwaltungslink');
|
||||
|
|
@ -105,7 +105,7 @@ function ManagementPortalPage() {
|
|||
formData.append('images', file);
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/manage/${token}/images`, {
|
||||
const res = await apiFetch(`/api/manage/${token}/images`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -146,7 +146,7 @@ function ManagementPortalPage() {
|
|||
const imageIds = newOrder.map(img => img.id);
|
||||
|
||||
// Use token-based management API
|
||||
const response = await fetch(`/api/manage/${token}/reorder`, {
|
||||
const response = await apiFetch(`/api/manage/${token}/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageIds: imageIds })
|
||||
|
|
@ -179,9 +179,9 @@ function ManagementPortalPage() {
|
|||
return (
|
||||
<div className="allContainer">
|
||||
<NavbarUpload />
|
||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div className="container flex-center" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||
<Loading />
|
||||
</Container>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -191,19 +191,15 @@ function ManagementPortalPage() {
|
|||
return (
|
||||
<div className="allContainer">
|
||||
<NavbarUpload />
|
||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
{error}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => navigate('/')}>
|
||||
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||
<div className="card text-center">
|
||||
<h2 style={{ color: '#f44336' }} className="mb-2">{error}</h2>
|
||||
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/')}>
|
||||
Zur Startseite
|
||||
</Button>
|
||||
</Card>
|
||||
</Container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -213,19 +209,17 @@ function ManagementPortalPage() {
|
|||
<div className="allContainer">
|
||||
<NavbarUpload />
|
||||
|
||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
|
||||
<CardContent>
|
||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
|
||||
Mein Upload verwalten
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
|
||||
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||
<div className="card mb-3">
|
||||
<div className="card-content">
|
||||
<h1 className="page-title text-center mb-2">Mein Upload verwalten</h1>
|
||||
<p className="page-subtitle text-center mb-4">
|
||||
Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
{/* Group Overview */}
|
||||
{group && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<div className="mb-4">
|
||||
<ImageGalleryCard
|
||||
item={group}
|
||||
showActions={false}
|
||||
|
|
@ -234,29 +228,25 @@ function ManagementPortalPage() {
|
|||
hidePreview={true}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Erteilte Einwilligungen:
|
||||
</Typography>
|
||||
<div className="mt-3">
|
||||
<h3 className="text-small" style={{ fontWeight: 600 }}>Erteilte Einwilligungen:</h3>
|
||||
<ConsentBadges group={group} />
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Images Dropzone */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Weitere Bilder hinzufügen
|
||||
</Typography>
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2" style={{ fontWeight: 600 }}>Weitere Bilder hinzufügen</h3>
|
||||
<MultiImageDropzone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
selectedImages={[]}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Image Descriptions Manager */}
|
||||
{group && group.images && group.images.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<div className="mb-4">
|
||||
<ImageDescriptionManager
|
||||
images={group.images}
|
||||
token={token}
|
||||
|
|
@ -264,44 +254,44 @@ function ManagementPortalPage() {
|
|||
onReorder={handleReorder}
|
||||
onRefresh={loadGroup}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Metadata Editor */}
|
||||
{group && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<div className="mb-4">
|
||||
<GroupMetadataEditor
|
||||
initialMetadata={group.metadata}
|
||||
token={token}
|
||||
onRefresh={loadGroup}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consent Manager */}
|
||||
{group && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<div className="mb-4">
|
||||
<ConsentManager
|
||||
initialConsents={group.consents}
|
||||
token={token}
|
||||
groupId={group.groupId}
|
||||
onRefresh={loadGroup}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Group Button */}
|
||||
{group && (
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="mt-4 flex-center">
|
||||
<DeleteGroupButton
|
||||
token={token}
|
||||
groupName={group.title || group.name || 'diese Gruppe'}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footerContainer">
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Container, Box } from '@mui/material';
|
||||
|
||||
// Services
|
||||
import { adminGet } from '../../services/adminApi';
|
||||
import { adminGet, adminRequest } from '../../services/adminApi';
|
||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
|
@ -15,6 +14,9 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
|
|||
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
||||
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
||||
|
||||
// UI
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
/**
|
||||
* ModerationGroupImagesPage - Admin page for moderating group images
|
||||
*
|
||||
|
|
@ -72,6 +74,35 @@ const ModerationGroupImagesPage = () => {
|
|||
loadGroup();
|
||||
}, [isAuthenticated, loadGroup]);
|
||||
|
||||
const handleReorder = async (newOrder) => {
|
||||
if (!group || !groupId) {
|
||||
console.error('No groupId available for reordering');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageIds = newOrder.map(img => img.id);
|
||||
|
||||
// Use admin API
|
||||
await adminRequest(`/api/admin/groups/${groupId}/reorder`, 'PUT', {
|
||||
imageIds: imageIds
|
||||
});
|
||||
|
||||
await Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Gespeichert',
|
||||
text: 'Die neue Reihenfolge wurde gespeichert.',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
|
||||
await loadGroup();
|
||||
} catch (error) {
|
||||
console.error('Error reordering images:', error);
|
||||
await handleAdminError(error, 'Reihenfolge speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <Loading />;
|
||||
if (error) return <div className="moderation-error">{error}</div>;
|
||||
|
|
@ -81,13 +112,15 @@ const ModerationGroupImagesPage = () => {
|
|||
<div className="allContainer">
|
||||
<Navbar />
|
||||
|
||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
||||
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||
{/* Image Descriptions Manager */}
|
||||
<ImageDescriptionManager
|
||||
images={group.images}
|
||||
groupId={groupId}
|
||||
onRefresh={loadGroup}
|
||||
mode="moderate"
|
||||
enableReordering={true}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{/* Group Metadata Editor */}
|
||||
|
|
@ -99,15 +132,15 @@ const ModerationGroupImagesPage = () => {
|
|||
/>
|
||||
|
||||
{/* Back Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<div className="flex-center mt-4">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/moderation')}
|
||||
>
|
||||
↩ Zurück zur Übersicht
|
||||
</button>
|
||||
</Box>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footerContainer"><Footer /></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Typography } from '@mui/material';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
||||
|
||||
// Services
|
||||
|
|
@ -17,8 +15,14 @@ import Navbar from '../ComponentUtils/Headers/Navbar';
|
|||
import Footer from '../ComponentUtils/Footer';
|
||||
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||
import DeletionLogSection from '../ComponentUtils/DeletionLogSection';
|
||||
import ConsentFilter from '../ComponentUtils/ConsentFilter/ConsentFilter';
|
||||
import StatsDisplay from '../ComponentUtils/StatsDisplay/StatsDisplay';
|
||||
import { getImageSrc } from '../../Utils/imageUtils';
|
||||
|
||||
// Styles
|
||||
import './Css/ModerationGroupsPage.css';
|
||||
import '../../App.css';
|
||||
|
||||
const ModerationGroupsPage = () => {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -268,24 +272,17 @@ const ModerationGroupsPage = () => {
|
|||
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
|
||||
</Helmet>
|
||||
|
||||
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Moderation
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<div className="container moderation-content">
|
||||
<div className="moderation-header">
|
||||
<h1>Moderation</h1>
|
||||
<div className="moderation-user-info">
|
||||
<button className="btn btn-success" onClick={exportConsentData}> Consent-Daten exportieren </button>
|
||||
{user?.username && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<p className="moderation-username">
|
||||
Eingeloggt als <strong>{user.username}</strong>
|
||||
</Typography>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
|
|
@ -295,98 +292,36 @@ const ModerationGroupsPage = () => {
|
|||
>
|
||||
{logoutPending ? 'Wird abgemeldet…' : 'Logout'}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="moderation-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-number">{pendingGroups.length}</span>
|
||||
<span className="stat-label">Wartend</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-number">{approvedGroups.length}</span>
|
||||
<span className="stat-label">Freigegeben</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-number">{groups.length}</span>
|
||||
<span className="stat-label">Gesamt</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Lösch-Historie */}
|
||||
<section className="moderation-section">
|
||||
<DeletionLogSection />
|
||||
</section>
|
||||
<StatsDisplay
|
||||
stats={[
|
||||
{ number: pendingGroups.length, label: 'Wartend' },
|
||||
{ number: approvedGroups.length, label: 'Freigegeben' },
|
||||
{ number: groups.length, label: 'Gesamt' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Filter und Export Controls */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
|
||||
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
Consent-Filter
|
||||
</FormLabel>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={consentFilters.workshop}
|
||||
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
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>
|
||||
<ConsentFilter
|
||||
filters={consentFilters}
|
||||
onChange={setConsentFilters}
|
||||
platforms={platforms}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Wartende Gruppen */}
|
||||
<section className="moderation-section">
|
||||
<ImageGallery
|
||||
items={pendingGroups}
|
||||
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
|
||||
title={`Wartende Freigabe (${pendingGroups.length})`}
|
||||
onApprove={approveGroup}
|
||||
onViewImages={viewGroupImages}
|
||||
onDelete={deleteGroup}
|
||||
|
|
@ -400,7 +335,7 @@ const ModerationGroupsPage = () => {
|
|||
<section className="moderation-section">
|
||||
<ImageGallery
|
||||
items={approvedGroups}
|
||||
title={`✅ Freigegebene Gruppen (${approvedGroups.length})`}
|
||||
title={`Freigegebene Gruppen (${approvedGroups.length})`}
|
||||
onApprove={approveGroup}
|
||||
onViewImages={viewGroupImages}
|
||||
onDelete={deleteGroup}
|
||||
|
|
@ -410,10 +345,7 @@ const ModerationGroupsPage = () => {
|
|||
/>
|
||||
</section>
|
||||
|
||||
{/* Lösch-Historie */}
|
||||
<section className="moderation-section">
|
||||
<DeletionLogSection />
|
||||
</section>
|
||||
|
||||
|
||||
{/* Bilder-Modal */}
|
||||
{showImages && selectedGroup && (
|
||||
|
|
@ -426,7 +358,7 @@ const ModerationGroupsPage = () => {
|
|||
onDeleteImage={deleteImage}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
<div className="footerContainer"><Footer /></div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -471,7 +403,7 @@ const ImageModal = ({ group, onClose, onDeleteImage }) => {
|
|||
<div className="image-actions">
|
||||
<span className="image-name">{image.originalName}</span>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDeleteImage(group.groupId, image.id)}
|
||||
title="Bild löschen"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, Typography, Container, Box } from '@mui/material';
|
||||
|
||||
// Components
|
||||
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
||||
|
|
@ -163,17 +162,17 @@ function MultiUploadPage() {
|
|||
<div className="allContainer">
|
||||
{<NavbarUpload />}
|
||||
|
||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
|
||||
<CardContent>
|
||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<div className="card-content">
|
||||
<h1 className="page-title">
|
||||
Project Image Uploader
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe dein Projekt in wenigen Worten.
|
||||
<br />
|
||||
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
{!uploading ? (
|
||||
<>
|
||||
|
|
@ -215,15 +214,11 @@ function MultiUploadPage() {
|
|||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', mt: 3, flexWrap: 'wrap' }}>
|
||||
<div className="flex-center">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleUpload}
|
||||
disabled={!canUpload()}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
padding: '12px 30px'
|
||||
}}
|
||||
>
|
||||
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
|
||||
</button>
|
||||
|
|
@ -231,14 +226,10 @@ function MultiUploadPage() {
|
|||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClearAll}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
padding: '12px 30px'
|
||||
}}
|
||||
>
|
||||
🗑️ Alle entfernen
|
||||
</button>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -254,85 +245,58 @@ function MultiUploadPage() {
|
|||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 4px 20px rgba(76, 175, 80, 0.4)',
|
||||
animation: 'slideIn 0.5s ease-out',
|
||||
'@keyframes slideIn': {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-20px)'
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)'
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '28px', fontWeight: 'bold', mb: 1 }}>
|
||||
<div className="success-box">
|
||||
<h2>
|
||||
✅ Upload erfolgreich!
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '18px', mb: 2 }}>
|
||||
</h2>
|
||||
<p>
|
||||
{uploadResult?.imageCount || 0} Bild{uploadResult?.imageCount === 1 ? '' : 'er'} {uploadResult?.imageCount === 1 ? 'wurde' : 'wurden'} hochgeladen.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<Box sx={{ bgcolor: 'rgba(255,255,255,0.2)', borderRadius: '8px', p: 2, mb: 2 }}>
|
||||
<Typography sx={{ fontSize: '14px', mb: 1 }}>
|
||||
<div className="info-box">
|
||||
<p className="text-small">
|
||||
Ihre Referenz-Nummer:
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', mb: 1 }}>
|
||||
</p>
|
||||
<p style={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{uploadResult?.groupId}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
</p>
|
||||
<p className="text-small" style={{ opacity: 0.9 }}>
|
||||
Notieren Sie sich diese Nummer für spätere Anfragen an das Team.
|
||||
</Typography>
|
||||
</Box>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadResult?.managementToken && (
|
||||
<Box sx={{
|
||||
bgcolor: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '8px',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
border: '2px solid rgba(255,255,255,0.3)'
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '16px', fontWeight: 'bold', mb: 1.5, color: '#2e7d32' }}>
|
||||
<div className="info-box-highlight">
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#2e7d32' }}>
|
||||
🔗 Verwaltungslink für Ihren Upload
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '13px', mb: 1.5, color: '#333' }}>
|
||||
</h3>
|
||||
<p style={{ fontSize: '13px', marginBottom: '12px', color: '#333' }}>
|
||||
Mit diesem Link können Sie später Ihre Bilder verwalten, Einwilligungen widerrufen oder die Gruppe löschen:
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<Box sx={{
|
||||
bgcolor: '#f5f5f5',
|
||||
p: 1.5,
|
||||
<div style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
mb: 1.5,
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Typography sx={{
|
||||
<p style={{
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
color: '#1976d2',
|
||||
wordBreak: 'break-all',
|
||||
flex: 1,
|
||||
minWidth: '200px'
|
||||
minWidth: '200px',
|
||||
margin: 0
|
||||
}}>
|
||||
{window.location.origin}/manage/{uploadResult.managementToken}
|
||||
</Typography>
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '6px 16px'
|
||||
}}
|
||||
onClick={() => {
|
||||
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
|
||||
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
|
||||
|
|
@ -351,43 +315,39 @@ function MultiUploadPage() {
|
|||
>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Typography sx={{ fontSize: '11px', color: '#666', mb: 0.5 }}>
|
||||
<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.
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '11px', color: '#666', fontStyle: 'italic' }}>
|
||||
</p>
|
||||
<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.
|
||||
</Typography>
|
||||
</Box>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Typography sx={{ fontSize: '13px', mb: 2, opacity: 0.95 }}>
|
||||
<p style={{ fontSize: '13px', marginBottom: '16px', opacity: 0.95 }}>
|
||||
Die Bilder werden geprüft und nach Freigabe auf dem Werkstatt-Monitor angezeigt.
|
||||
{' '}Bei Social Media Einwilligung werden sie entsprechend veröffentlicht.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<Typography sx={{ fontSize: '12px', mb: 3, opacity: 0.9 }}>
|
||||
<p style={{ fontSize: '12px', marginBottom: '24px', opacity: 0.9 }}>
|
||||
<strong>Fragen oder Widerruf?</strong> Kontakt: <strong>it@hobbyhimmel.de</strong>
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="btn btn-success"
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
padding: '12px 30px'
|
||||
}}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
👍 Weitere Bilder hochladen
|
||||
</button>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footerContainer">
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Container } from '@mui/material';
|
||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||
import Footer from '../ComponentUtils/Footer';
|
||||
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
|
||||
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||
import { apiFetch } from '../../Utils/apiFetch';
|
||||
|
||||
|
||||
const PublicGroupImagesPage = () => {
|
||||
|
|
@ -22,7 +22,7 @@ const PublicGroupImagesPage = () => {
|
|||
try {
|
||||
setLoading(true);
|
||||
// Public endpoint (no moderation controls)
|
||||
const res = await fetch(`/api/groups/${groupId}`);
|
||||
const res = await apiFetch(`/api/groups/${groupId}`);
|
||||
if (!res.ok) throw new Error('Nicht gefunden');
|
||||
const data = await res.json();
|
||||
setGroup(data);
|
||||
|
|
@ -41,7 +41,7 @@ const PublicGroupImagesPage = () => {
|
|||
<div className="allContainer">
|
||||
<Navbar />
|
||||
|
||||
<Container maxWidth="lg" className="page-container" style={{ marginTop: '40px' }}>
|
||||
<div className="container page-container" style={{ marginTop: '40px' }}>
|
||||
<ImageGalleryCard
|
||||
item={group}
|
||||
showActions={false}
|
||||
|
|
@ -69,7 +69,7 @@ const PublicGroupImagesPage = () => {
|
|||
return acc;
|
||||
}, {}) : {}}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<div className="footerContainer"><Footer /></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Home as HomeIcon,
|
||||
ExitToApp as ExitIcon
|
||||
|
|
@ -172,12 +166,12 @@ function SlideshowPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={fullscreenSx}>
|
||||
<Box sx={loadingContainerSx}>
|
||||
<CircularProgress sx={{ color: 'white', mb: 2 }} />
|
||||
<Typography sx={{ color: 'white' }}>Slideshow wird geladen...</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid rgba(255,255,255,0.3)', borderTop: '4px solid white', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '16px' }}></div>
|
||||
<p style={{ color: 'white', margin: 0 }}>Slideshow wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -192,27 +186,27 @@ function SlideshowPage() {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={fullscreenSx}>
|
||||
<Box sx={loadingContainerSx}>
|
||||
<Typography sx={{ color: 'white', fontSize: '24px' }}>{error}</Typography>
|
||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>{error}</p>
|
||||
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
|
||||
<HomeIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentGroup || !currentImage) {
|
||||
return (
|
||||
<Box sx={fullscreenSx}>
|
||||
<Box sx={loadingContainerSx}>
|
||||
<Typography sx={{ color: 'white', fontSize: '24px' }}>Keine Bilder verfügbar</Typography>
|
||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>Keine Bilder verfügbar</p>
|
||||
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
|
||||
<HomeIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -275,41 +269,41 @@ function SlideshowPage() {
|
|||
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
|
||||
|
||||
return (
|
||||
<Box sx={fullscreenSx}>
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||
{/* Navigation Buttons */}
|
||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
||||
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
|
||||
<HomeIcon />
|
||||
</IconButton>
|
||||
</button>
|
||||
|
||||
<IconButton sx={exitButtonSx} onClick={() => navigate('/')} title="Slideshow beenden">
|
||||
<button style={{ position: 'absolute', top: '20px', right: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Slideshow beenden" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
|
||||
<ExitIcon />
|
||||
</IconButton>
|
||||
</button>
|
||||
|
||||
{/* Hauptbild */}
|
||||
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
|
||||
<img src={getImageSrc(currentImage, false)} alt={currentImage.originalName} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', transition: `opacity ${TRANSITION_TIME}ms ease-in-out`, opacity: fadeOut ? 0 : 1 }} />
|
||||
|
||||
{/* Bildbeschreibung (wenn vorhanden) */}
|
||||
{currentImage.imageDescription && (
|
||||
<Box sx={imageDescriptionSx}>
|
||||
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography>
|
||||
</Box>
|
||||
<div style={{ position: 'fixed', bottom: '140px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'rgba(0,0,0,0.7)', padding: '15px 30px', borderRadius: '8px', maxWidth: '80%', textAlign: 'center', backdropFilter: 'blur(5px)', zIndex: 10002 }}>
|
||||
<p style={{ color: 'white', fontSize: '18px', margin: 0, lineHeight: 1.4, fontFamily: 'Open Sans, sans-serif' }}>{currentImage.imageDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Beschreibung */}
|
||||
<Box sx={descriptionContainerSx}>
|
||||
<div style={{ position: 'fixed', left: '40px', bottom: '40px', backgroundColor: 'rgba(0,0,0,0.8)', padding: '25px 35px', borderRadius: '12px', maxWidth: '35vw', minWidth: '260px', textAlign: 'left', backdropFilter: 'blur(5px)', zIndex: 10001, boxShadow: '0 4px 24px rgba(0,0,0,0.4)' }}>
|
||||
{/* Titel */}
|
||||
<Typography sx={titleTextSx}>{currentGroup.title || 'Unbenanntes Projekt'}</Typography>
|
||||
<h2 style={{ color: 'white', fontSize: '28px', fontWeight: 500, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.title || 'Unbenanntes Projekt'}</h2>
|
||||
|
||||
{/* Jahr und Name */}
|
||||
<Typography sx={yearAuthorTextSx}>{currentGroup.year}{currentGroup.name && ` • ${currentGroup.name}`}</Typography>
|
||||
<p style={{ color: '#FFD700', fontSize: '18px', fontWeight: 400, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.year}{currentGroup.name && ` • ${currentGroup.name}`}</p>
|
||||
|
||||
{/* Beschreibung (wenn vorhanden) */}
|
||||
{currentGroup.description && <Typography sx={descriptionTextSx}>{currentGroup.description}</Typography>}
|
||||
{currentGroup.description && <p style={{ color: '#E0E0E0', fontSize: '16px', fontWeight: 300, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif', lineHeight: 1.4 }}>{currentGroup.description}</p>}
|
||||
|
||||
{/* Meta-Informationen */}
|
||||
<Typography sx={metaTextSx}>Bild {currentImageIndex + 1} von {currentGroup.images.length} • Slideshow {currentGroupIndex + 1} von {allGroups.length}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<p style={{ color: '#999', fontSize: '12px', marginTop: '8px', marginBottom: 0, fontFamily: 'Open Sans, sans-serif' }}>Bild {currentImageIndex + 1} von {currentGroup.images.length} • Slideshow {currentGroupIndex + 1} von {allGroups.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
62
frontend/src/Utils/apiClient.js
Normal file
62
frontend/src/Utils/apiClient.js
Normal 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;
|
||||
116
frontend/src/Utils/apiFetch.js
Normal file
116
frontend/src/Utils/apiFetch.js
Normal 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;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { apiFetch } from './apiFetch';
|
||||
|
||||
// Batch-Upload Funktion für mehrere Bilder
|
||||
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
|
||||
if (!images || images.length === 0) {
|
||||
|
|
@ -29,7 +31,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/batch', {
|
||||
const response = await apiFetch('/api/upload/batch', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
|
@ -50,7 +52,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
|
|||
// Einzelne Gruppe abrufen
|
||||
export const fetchGroup = async (groupId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${groupId}`);
|
||||
const response = await apiFetch(`/api/groups/${groupId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
|
@ -67,7 +69,7 @@ export const fetchGroup = async (groupId) => {
|
|||
// Alle Gruppen abrufen
|
||||
export const fetchAllGroups = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/groups');
|
||||
const response = await apiFetch('/api/groups');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
|
@ -84,7 +86,7 @@ export const fetchAllGroups = async () => {
|
|||
// Gruppe löschen
|
||||
export const deleteGroup = async (groupId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${groupId}`, {
|
||||
const response = await apiFetch(`/api/groups/${groupId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import axios from 'axios'
|
||||
import apiClient from './apiClient'
|
||||
|
||||
//import swal from 'sweetalert';
|
||||
import Swal from 'sweetalert2/dist/sweetalert2.js'
|
||||
|
|
@ -22,7 +22,7 @@ export async function sendRequest(file, handleLoading, handleResponse) {
|
|||
handleLoading()
|
||||
|
||||
try {
|
||||
const res = await axios.post(window._env_.API_URL + '/upload', formData, {
|
||||
const res = await apiClient.post('/upload', formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
|
|
|
|||
29151
package-lock.json
generated
Normal file
29151
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "project-image-uploader",
|
||||
"version": "1.2.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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
38
scripts/bump-version.sh
Executable file
38
scripts/bump-version.sh
Executable 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
0
scripts/examples.sh
Normal file → Executable file
192
scripts/release.sh
Executable file
192
scripts/release.sh
Executable file
|
|
@ -0,0 +1,192 @@
|
|||
#!/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}Nächste Schritte:${NC}"
|
||||
echo " git push && git push --tags"
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ Release v${NEW_VERSION} fertig!${NC}"
|
||||
49
scripts/sync-version.sh
Executable file
49
scripts/sync-version.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/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. Footer.js Fallback aktualisieren
|
||||
echo " ├─ Frontend Footer.js Fallback..."
|
||||
sed -i "s/window\._env_\?\.APP_VERSION || '[0-9]\+\.[0-9]\+\.[0-9]\+'/window._env_?.APP_VERSION || '${FRONTEND_VERSION}'/" frontend/src/Components/ComponentUtils/Footer.js
|
||||
|
||||
# 3. 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
|
||||
|
||||
# 4. 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
|
||||
|
||||
# 5. 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}"
|
||||
echo ""
|
||||
echo "Nächste Schritte:"
|
||||
echo " 1. CHANGELOG.md manuell aktualisieren"
|
||||
echo " 2. Git commit: git add -A && git commit -m 'chore: bump version to v${FRONTEND_VERSION}'"
|
||||
echo " 3. Git tag: git tag v${FRONTEND_VERSION}"
|
||||
echo " 4. Push: git push && git push --tags"
|
||||
162
test-error-page.html
Normal file
162
test-error-page.html
Normal 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
150
test-loading.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user