feat: Error handling system and animated error pages

- Add ErrorBoundary component for React error handling
- Create animated error pages (403, 404, 500, 502, 503)
- Implement ErrorAnimation component with seven-segment display
- Add apiClient (axios) and apiFetch (fetch) wrappers with automatic error page redirects
- Migrate critical API calls to use new error handling
- Update font from Roboto to Open Sans across all components
- Remove unused CLIENT_URL from docker-compose files
- Rename 404Page.css to ErrorPage.css for consistency
- Add comprehensive ERROR_HANDLING.md documentation
This commit is contained in:
Matthias Lotz 2025-11-26 22:42:55 +01:00
parent 920a81e075
commit 25dda32c4e
30 changed files with 952 additions and 125 deletions

View File

@ -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:

View File

@ -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
View File

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

View File

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

View File

@ -3,13 +3,15 @@
/* 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; }

View File

@ -3,11 +3,16 @@ 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 ForbiddenPage from './Components/Pages/403Page.js';
import InternalServerErrorPage from './Components/Pages/500Page.js';
import BadGatewayPage from './Components/Pages/502Page.js';
import ServiceUnavailablePage from './Components/Pages/503Page.js';
// Lazy loaded (internal only) - Code Splitting für Performance
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
@ -52,13 +57,20 @@ 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 />} />
{/* Error Pages */}
<Route path="/error/403" element={<ForbiddenPage />} />
<Route path="/error/500" element={<InternalServerErrorPage />} />
<Route path="/error/502" element={<BadGatewayPage />} />
<Route path="/error/503" element={<ServiceUnavailablePage />} />
{/* Internal Only Routes - nur auf internal host geladen */}
{hostConfig.isInternal && (
@ -112,6 +124,7 @@ function App() {
</Suspense>
</Router>
</AdminSessionProvider>
</ErrorBoundary>
);
}

View File

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

View File

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

View File

@ -185,7 +185,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;
@ -294,7 +294,7 @@
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: 'Roboto', sans-serif;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
resize: vertical;
min-height: 50px;

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
import React from 'react';
import InternalServerErrorPage from '../Pages/500Page';
/**
* 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 <InternalServerErrorPage />;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
import DescriptionInput from './MultiUpload/DescriptionInput';
import { adminRequest } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import { apiFetch } from '../../Utils/apiFetch';
/**
* Manages group metadata with save functionality
@ -76,7 +77,7 @@ function GroupMetadataEditor({
if (isModerateMode) {
await adminRequest(endpoint, method, metadata);
} else {
const res = await fetch(endpoint, {
const res = await apiFetch(endpoint, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata)

View File

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

View File

@ -0,0 +1,30 @@
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'
function ForbiddenPage() {
const hostConfig = getHostConfig();
return (
<div className="allContainerNoBackground">
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
<div className="containerError">
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<h1>403 - Zugriff verweigert</h1>
<p>Sie haben keine Berechtigung, auf diese Ressource zuzugreifen.</p>
<ErrorAnimation errorCode="403" />
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
Zurück zur Startseite
</a>
</div>
</div>
</div>
)
}
export default ForbiddenPage

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
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'
function InternalServerErrorPage() {
const hostConfig = getHostConfig();
return (
<div className="allContainerNoBackground">
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
<div className="containerError">
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<h1>500 - Interner Serverfehler</h1>
<p>Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut.</p>
<ErrorAnimation errorCode="500" />
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
Zurück zur Startseite
</a>
</div>
</div>
</div>
)
}
export default InternalServerErrorPage

View File

@ -0,0 +1,30 @@
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'
function BadGatewayPage() {
const hostConfig = getHostConfig();
return (
<div className="allContainerNoBackground">
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
<div className="containerError">
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<h1>502 - Bad Gateway</h1>
<p>Der Server hat eine ungültige Antwort erhalten. Bitte versuchen Sie es später erneut.</p>
<ErrorAnimation errorCode="502" />
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
Zurück zur Startseite
</a>
</div>
</div>
</div>
)
}
export default BadGatewayPage

View File

@ -0,0 +1,30 @@
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'
function ServiceUnavailablePage() {
const hostConfig = getHostConfig();
return (
<div className="allContainerNoBackground">
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
<div className="containerError">
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<h1>503 - Service nicht verfügbar</h1>
<p>Der Service ist vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.</p>
<ErrorAnimation errorCode="503" />
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
Zurück zur Startseite
</a>
</div>
</div>
</div>
)
}
export default ServiceUnavailablePage

View File

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

View File

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

View File

@ -12,6 +12,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 +37,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 +106,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 +147,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 })

View File

@ -5,6 +5,7 @@ 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 +23,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);

View File

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

View File

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

View File

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

View File

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

162
test-loading.html Normal file
View File

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