Initial Commit

This commit is contained in:
Matthias Lotz 2025-10-15 21:33:00 +02:00
parent 7ea96bfeca
commit 48bf6f2074
138 changed files with 41489 additions and 6 deletions

552
ERWEITERUNG.md Normal file
View File

@ -0,0 +1,552 @@
# Image-Uploader Erweiterung: Multi-Image Upload mit Beschreibung
## 🎯 Ziel
Erweiterung der bestehenden Single-Image-Upload-Funktionalität zu einem Multi-Image-Upload mit Beschreibungstext für spätere Slideshow-Nutzung.
## 📊 Aufwandsschätzung
**Geschätzter Aufwand: 8-12 Stunden** (verteilt auf 2-3 Arbeitstage)
### Komplexitätsbewertung: ⭐⭐⭐☆☆ (Mittel)
## 🔄 Änderungsübersicht
### Frontend Änderungen (5-7 Stunden)
- **Neue Multi-Upload-Komponente**
- **UI für Beschreibungstext**
- **Vorschau-Galerie**
- **Upload-Progress für mehrere Dateien**
### Backend Änderungen (2-3 Stunden)
- **Neue API-Endpoints**
- **Datenbank/JSON-Struktur für Upload-Gruppen**
- **Batch-Upload-Verarbeitung**
### Integration & Testing (1-2 Stunden)
- **Frontend-Backend-Integration**
- **Error Handling**
- **UI/UX Tests**
---
## 🏗️ Technische Umsetzung
### 1. Backend-Erweiterungen
#### 1.1 Neue Datenstruktur
```javascript
// Neue Upload-Group Struktur
{
groupId: "unique-group-id",
description: "Benutzer-Beschreibung",
uploadDate: "2025-10-11T10:30:00Z",
images: [
{
fileName: "abc123.jpg",
originalName: "foto1.jpg",
filePath: "/upload/abc123.jpg",
uploadOrder: 1
},
{
fileName: "def456.png",
originalName: "foto2.png",
filePath: "/upload/def456.png",
uploadOrder: 2
}
]
}
```
#### 1.2 Neue API-Endpoints
- `POST /api/upload/batch` - Multi-Image Upload
- `GET /api/groups/:groupId` - Upload-Gruppe abrufen
- `GET /api/groups` - Alle Upload-Gruppen auflisten
- `GET /api/slideshow/:groupId` - Slideshow-Daten
#### 1.3 Dateien zu erstellen/ändern:
```
backend/src/
├── routes/
│ ├── upload.js # ✏️ Erweitern
│ ├── batch-upload.js # 🆕 Neu
│ └── groups.js # 🆕 Neu
├── models/
│ └── uploadGroup.js # 🆕 Neu
├── utils/
│ └── groupStorage.js # 🆕 Neu (JSON-basiert)
└── constants.js # ✏️ Neue Endpoints hinzufügen
```
### 2. Frontend-Erweiterungen
#### 2.1 Neue Komponenten
```
frontend/src/Components/
├── ComponentUtils/
│ ├── MultiImageUpload.js # 🆕 Haupt-Upload-Komponente
│ ├── ImagePreviewGallery.js # 🆕 Vorschau der ausgewählten Bilder
│ ├── DescriptionInput.js # 🆕 Textfeld für Beschreibung
│ ├── UploadProgress.js # 🆕 Progress-Anzeige für alle Dateien
│ └── Css/
│ ├── MultiUpload.css # 🆕 Styling
│ └── ImageGallery.css # 🆕 Galerie-Styling
├── Pages/
│ ├── MultiUploadPage.js # 🆕 Neue Seite für Multi-Upload
│ ├── SlideshowPage.js # 🆕 Slideshow-Anzeige
│ └── GroupsOverviewPage.js # 🆕 Übersicht aller Upload-Gruppen
└── Utils/
└── batchUpload.js # 🆕 Batch-Upload-Logik
```
#### 2.2 Routing-Erweiterungen
```javascript
// Neue Routen in App.js
<Route path="/multi-upload" component={MultiUploadPage} />
<Route path="/slideshow/:groupId" component={SlideshowPage} />
<Route path="/groups" component={GroupsOverviewPage} />
```
---
## 📋 Detaillierte Implementierungsschritte
### Phase 1: Backend-Grundlage (2-3h)
#### Schritt 1.1: Upload-Gruppen Datenmodell
```javascript
// backend/src/models/uploadGroup.js
class UploadGroup {
constructor(description) {
this.groupId = generateId();
this.description = description;
this.uploadDate = new Date().toISOString();
this.images = [];
}
addImage(fileName, originalName, uploadOrder) {
this.images.push({
fileName,
originalName,
filePath: `/upload/${fileName}`,
uploadOrder
});
}
}
```
#### Schritt 1.2: JSON-basierte Speicherung
```javascript
// backend/src/utils/groupStorage.js
const fs = require('fs');
const path = require('path');
const GROUPS_FILE = path.join(__dirname, '../data/upload-groups.json');
class GroupStorage {
static saveGroup(group) {
// JSON-Datei lesen, Gruppe hinzufügen, zurückschreiben
}
static getGroup(groupId) {
// Gruppe aus JSON-Datei laden
}
static getAllGroups() {
// Alle Gruppen laden
}
}
```
#### Schritt 1.3: Batch-Upload API
```javascript
// backend/src/routes/batch-upload.js
router.post('/api/upload/batch', (req, res) => {
const { description } = req.body;
const files = req.files.images; // Array von Dateien
const group = new UploadGroup(description);
// Alle Dateien verarbeiten
files.forEach((file, index) => {
const fileName = generateId() + '.' + getFileExtension(file.name);
file.mv(`upload/${fileName}`);
group.addImage(fileName, file.name, index + 1);
});
GroupStorage.saveGroup(group);
res.json({ groupId: group.groupId, message: 'Upload successful' });
});
```
### Phase 2: Frontend Multi-Upload UI (3-4h)
#### Schritt 2.1: Multi-Image Dropzone
```javascript
// frontend/src/Components/ComponentUtils/MultiImageUpload.js
import { useDropzone } from 'react-dropzone';
function MultiImageUpload({ onImagesSelected }) {
const { getRootProps, getInputProps, acceptedFiles } = useDropzone({
accept: 'image/*',
multiple: true,
onDrop: (files) => {
onImagesSelected(files);
}
});
return (
<div {...getRootProps()} className="multi-dropzone">
<input {...getInputProps()} />
<p>Ziehe mehrere Bilder hierher oder klicke zum Auswählen</p>
<p>({acceptedFiles.length} Dateien ausgewählt)</p>
</div>
);
}
```
#### Schritt 2.2: Bild-Vorschau Galerie
```javascript
// frontend/src/Components/ComponentUtils/ImagePreviewGallery.js
function ImagePreviewGallery({ images, onRemoveImage, onReorderImages }) {
return (
<div className="image-preview-gallery">
{images.map((image, index) => (
<div key={index} className="image-preview-item">
<img src={URL.createObjectURL(image)} alt={`Preview ${index + 1}`} />
<button onClick={() => onRemoveImage(index)}>✕</button>
<div className="image-order">{index + 1}</div>
</div>
))}
</div>
);
}
```
#### Schritt 2.3: Beschreibungs-Input
```javascript
// frontend/src/Components/ComponentUtils/DescriptionInput.js
function DescriptionInput({ description, onDescriptionChange, maxLength = 500 }) {
return (
<div className="description-input">
<label>Beschreibung für diese Bildersammlung:</label>
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
maxLength={maxLength}
placeholder="Beschreibe diese Bildersammlung für die spätere Slideshow..."
/>
<div className="character-count">{description.length}/{maxLength}</div>
</div>
);
}
```
### Phase 3: Upload-Logik & Progress (2-3h)
#### Schritt 3.1: Batch-Upload Funktion
```javascript
// frontend/src/Utils/batchUpload.js
async function uploadImageBatch(images, description, onProgress) {
const formData = new FormData();
images.forEach((image, index) => {
formData.append('images', image);
});
formData.append('description', description);
try {
const response = await fetch('/api/upload/batch', {
method: 'POST',
body: formData,
onUploadProgress: (progressEvent) => {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
onProgress(progress);
}
});
return await response.json();
} catch (error) {
throw new Error(`Upload failed: ${error.message}`);
}
}
```
#### Schritt 3.2: Upload Progress Komponente
```javascript
// frontend/src/Components/ComponentUtils/UploadProgress.js
function UploadProgress({ progress, currentFile, totalFiles }) {
return (
<div className="upload-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
</div>
<div className="progress-text">
{currentFile && `Uploading: ${currentFile} (${Math.round(progress)}%)`}
{totalFiles && `${currentFile} von ${totalFiles} Dateien`}
</div>
</div>
);
}
```
### Phase 4: Hauptseite Integration (1-2h)
#### Schritt 4.1: Multi-Upload Seite
```javascript
// frontend/src/Components/Pages/MultiUploadPage.js
function MultiUploadPage() {
const [selectedImages, setSelectedImages] = useState([]);
const [description, setDescription] = useState('');
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const handleUpload = async () => {
if (selectedImages.length === 0) return;
setUploading(true);
try {
const result = await uploadImageBatch(
selectedImages,
description,
setUploadProgress
);
// Redirect zur Slideshow oder Erfolgsseite
history.push(`/slideshow/${result.groupId}`);
} catch (error) {
// Error handling
} finally {
setUploading(false);
}
};
return (
<div className="multi-upload-page">
<Navbar />
<div className="upload-container">
<MultiImageUpload onImagesSelected={setSelectedImages} />
{selectedImages.length > 0 && (
<>
<ImagePreviewGallery
images={selectedImages}
onRemoveImage={removeImageAtIndex}
onReorderImages={reorderImages}
/>
<DescriptionInput
description={description}
onDescriptionChange={setDescription}
/>
<button
onClick={handleUpload}
disabled={uploading || selectedImages.length === 0}
className="upload-button"
>
{uploading ? 'Uploading...' : `${selectedImages.length} Bilder hochladen`}
</button>
</>
)}
{uploading && (
<UploadProgress
progress={uploadProgress}
totalFiles={selectedImages.length}
/>
)}
</div>
</div>
);
}
```
### Phase 5: Slideshow & Navigation (2h)
#### Schritt 5.1: Slideshow Komponente
```javascript
// frontend/src/Components/Pages/SlideshowPage.js
function SlideshowPage() {
const { groupId } = useParams();
const [group, setGroup] = useState(null);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
useEffect(() => {
fetch(`/api/groups/${groupId}`)
.then(res => res.json())
.then(setGroup);
}, [groupId]);
if (!group) return <div>Loading...</div>;
return (
<div className="slideshow-page">
<div className="slideshow-header">
<h1>{group.description}</h1>
<p>Hochgeladen am: {new Date(group.uploadDate).toLocaleDateString()}</p>
</div>
<div className="slideshow-container">
<img
src={group.images[currentImageIndex].filePath}
alt={`Bild ${currentImageIndex + 1}`}
className="slideshow-image"
/>
<div className="slideshow-controls">
<button onClick={() => setCurrentImageIndex(prev =>
prev > 0 ? prev - 1 : group.images.length - 1
)}>
Vorheriges
</button>
<span>{currentImageIndex + 1} / {group.images.length}</span>
<button onClick={() => setCurrentImageIndex(prev =>
prev < group.images.length - 1 ? prev + 1 : 0
)}>
Nächstes
</button>
</div>
</div>
<div className="slideshow-thumbnails">
{group.images.map((image, index) => (
<img
key={index}
src={image.filePath}
alt={`Thumbnail ${index + 1}`}
className={`thumbnail ${index === currentImageIndex ? 'active' : ''}`}
onClick={() => setCurrentImageIndex(index)}
/>
))}
</div>
</div>
);
}
```
---
## 🎨 UI/UX Verbesserungen
### Drag & Drop Features
- **Bilder-Reihenfolge ändern**: Drag & Drop in der Vorschau
- **Bilder entfernen**: X-Button auf jedem Vorschaubild
- **Bulk-Aktionen**: Alle entfernen, Reihenfolge umkehren
### Responsive Design
- **Mobile-optimiert**: Touch-friendly Upload und Slideshow
- **Tablet-Ansicht**: Optimierte Galerie-Darstellung
- **Desktop**: Erweiterte Features wie Keyboard-Navigation
### Benutzerfreundlichkeit
- **Progress-Feedback**: Echtzeitanzeige des Upload-Fortschritts
- **Error Handling**: Klare Fehlermeldungen bei Upload-Problemen
- **Auto-Save**: Beschreibung zwischenspeichern
- **Vorschau-Modus**: Slideshow vor Upload testen
---
## 🧪 Testing-Strategie
### Unit Tests
- Upload-Gruppe Datenmodell
- Batch-Upload API-Endpoints
- Frontend-Komponenten (Jest/React Testing Library)
### Integration Tests
- End-to-End Upload-Flow
- Slideshow-Navigation
- Error-Szenarien
### Performance Tests
- Multiple große Dateien (>10MB)
- Viele kleine Dateien (>50 Bilder)
- Speicher-Verbrauch bei großen Uploads
---
## 🚀 Deployment-Überlegungen
### Datei-Größe Limits
```javascript
// Backend-Konfiguration erweitern
app.use(fileUpload({
limits: {
fileSize: 50 * 1024 * 1024, // 50MB pro Datei
files: 20 // Max 20 Dateien pro Upload
},
}));
```
### Speicher-Management
- **Cleanup-Job**: Alte Upload-Gruppen nach X Tagen löschen
- **Komprimierung**: Automatische Bildkomprimierung für große Dateien
- **CDN-Integration**: Für bessere Performance bei vielen Bildern
### Sicherheit
- **File-Type Validation**: Nur erlaubte Bildformate
- **Virus-Scanning**: Optional für Produktionsumgebung
- **Rate Limiting**: Upload-Beschränkungen pro IP/User
---
## 📈 Erweiterungs-Möglichkeiten (Zukunft)
### Erweiterte Features
- **Benutzer-Accounts**: Upload-Gruppen Benutzern zuordnen
- **Tagging-System**: Bilder mit Tags versehen
- **Sharing**: Upload-Gruppen per Link teilen
- **Export**: Slideshow als Video oder PDF exportieren
### Slideshow-Features
- **Autoplay**: Automatischer Bildwechsel
- **Übergangs-Effekte**: Fade, Slide, etc.
- **Hintergrundmusik**: Audio-Upload für Slideshows
- **Vollbild-Modus**: Immersive Slideshow-Erfahrung
### Admin-Features
- **Upload-Statistiken**: Dashboard mit Nutzungsmetriken
- **Content-Moderation**: Gemeldete Inhalte prüfen
- **Bulk-Operations**: Mehrere Gruppen gleichzeitig verwalten
---
## ⚡ Quick-Start Implementierung
Für einen schnellen Proof-of-Concept (2-3 Stunden):
1. **Backend**: Erweitere `/upload` Route für Array-Handling
2. **Frontend**: Ändere bestehende Dropzone auf `multiple: true`
3. **Einfache Galerie**: Zeige alle Bilder einer "Session" an
4. **Basis-Slideshow**: Einfache Vor/Zurück-Navigation
Dies würde eine funktionale Basis schaffen, die später ausgebaut werden kann.
---
## 🎯 Erfolgskriterien
### Must-Have
- ✅ Mehrere Bilder gleichzeitig auswählen
- ✅ Beschreibungstext hinzufügen
- ✅ Upload als zusammengehörige Gruppe
- ✅ Einfache Slideshow-Anzeige
- ✅ Mobile-Kompatibilität
### Nice-to-Have
- 🎨 Drag & Drop Reihenfolge ändern
- 📊 Upload-Progress mit Details
- 🖼️ Thumbnail-Navigation in Slideshow
- 💾 Auto-Save der Beschreibung
- 🔄 Batch-Operations (alle entfernen, etc.)
### Future Features
- 👤 User-Management
- 🏷️ Tagging-System
- 📤 Export-Funktionen
- 🎵 Audio-Integration
---
**Fazit**: Die Erweiterung ist gut machbar und baut logisch auf der bestehenden Architektur auf. Der modulare Ansatz ermöglicht schrittweise Implementierung und spätere Erweiterungen.

20
LICENSE
View File

@ -1,9 +1,21 @@
MIT License
Copyright (c) 2025 hobbyhimmel
Copyright (c) 2021 Valentin Zwerschke
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

297
README.md
View File

@ -1,3 +1,296 @@
# Project-Image-Uploader
# Image Uploader with Multi-Upload & Slideshow
A self-hosted image uploader with multi-image upload capabilities and automatic slideshow functionality.
## Features
**Multi-Image Upload**: Upload multiple images at once with batch processing
**Slideshow Mode**: Automatic fullscreen slideshow with smooth transitions
**Persistent Storage**: Docker volumes ensure data persistence across restarts
**Clean UI**: Minimalist design focused on user experience
**Self-Hosted**: Complete control over your data and infrastructure
**Lightweight**: Built with modern web technologies for optimal performance
## What's New
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
- Multi-image batch upload with progress tracking
- Automatic slideshow presentation mode
- Image grouping with descriptions
- Random slideshow rotation
- Keyboard navigation support
- Mobile-responsive design- Mobile-responsive design
## Quick Start
### Docker Deployment (Recommended)
1. **Create docker-compose.yml**:
```yaml
services:
frontend:
image: vallezw/image-uploader-client
ports:
- "80:80"
depends_on:
- backend
environment:
- "API_URL=http://localhost:5000"
- "CLIENT_URL=http://localhost"
container_name: frontend
backend:
image: vallezw/image-uploader-backend
environment:
- "CLIENT_URL=http://localhost"
container_name: backend
backend:
image: vallezw/image-uploader-client
ports:
- "80:80"
container_name: frontend
image: vallezw/image-uploader-backend
ports:
- "5000:5000"
container_name: backend
volumes:
- app-data:/usr/src/app/src/upload
depends_on:
- app-data:/usr/src/app/src/data
- backend
volumes:
app-data:
environment:
- "API_URL=http://localhost:5000"
- "CLIENT_URL=http://localhost"
driver: local
```
2. **Start the application**:
```bash
docker compose up -d
```
3. **Access the application**:
- Upload Interface: `http://localhost`
- Backend: `http://localhost:5000`
- Slideshow Mode: `http://localhost/slideshow`
### Multi-Image Upload
1. Visit `http://localhost`
2. Drag & drop multiple images or click to select
3. Add an optional description for your image collection
4. Click "Upload Images" to process the batch
5. Images are automatically grouped for slideshow viewing
### Slideshow Mode
- **Automatic Access**: Navigate to `http://localhost/slideshow`
- **Features**:
- Fullscreen presentation
- 4-second display per image
- Automatic progression through all slideshow collections
- Random selection of next slideshow after completing current one
- Smooth fade transitions (0.5s)
- **Keyboard Controls**:
- **ESC**: Exit slideshow / Return to upload page
- **Spacebar / Arrow Right**: Manually advance to next image
- **Home Button**: Return to main upload interface
### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication)
- **Authentication**: HTTP Basic Auth (username: admin, password: set during setup)
- **Features**:
- Review pending image groups before public display
- Approve or reject submitted collections
- Delete individual images from approved groups
- View group details (title, creator, description, image count)
- Bulk moderation actions
- **Group Management**: Navigate to `http://localhost/groups` (requires authentication)
- Overview of all approved slideshow collections
- Delete entire groups
- Launch slideshow mode from any group
- View group statistics and metadata
**Security Features**:
- Password protected access via nginx HTTP Basic Auth
- Hidden from search engines (`robots.txt` + `noindex` meta tags)
- No public links or references in main interface
## Data Structure
### Slideshow JSON Format
```json
[
{
"groupId": "0fSwazTOU",
"description": "My Photo Collection",
"uploadDate": "2025-10-11T14:34:48.159Z",
"images":
{
"fileName": "ZMmHXzHbqw.jpg",
"originalName": "vacation-photo-1.jpg",
"filePath": "/upload/ZMmHXzHbqw.jpg",
"uploadOrder": 1
},
{
"fileName": "tjjnngOmXS.jpg",
"originalName": "vacation-photo-2.jpg",
"filePath": "/upload/tjjnngOmXS.jpg",
"uploadOrder": 2
}
],
"imageCount": 21
}
]
```
### Field Descriptions
| Field | Type | Description |
|-------|------|-------------|
| `groupId` | string | Unique identifier generated with shortid |
| `description` | string | User-provided description for the image collection |
| `uploadDate` | string | ISO timestamp of upload completion |
| `images` | array | Array of image objects in the collection |
| `imageCount` | number | Total number of images in the group |
### Image Object Structure
| Field | Type | Description |
|-------|------|-------------|
| `fileName` | string | Generated unique filename for storage |
| `originalName` | string | Original filename from user's device |
| `filePath` | string | Relative path to the stored image file |
| `uploadOrder` | number | Sequential order within the slideshow (1, 2, 3...) |
## Architecture
### Backend (Node.js + Express)
- **Multi-upload API**: `/api/upload/batch` - Handles batch file processing
- **Groups API**: `/api/groups` - Retrieves slideshow collections
- **File Storage**: Organized in `/upload` directory
- **Metadata Storage**: JSON files in `/data` directory
### Frontend (React + Material-UI)
- **Multi-Upload Interface**: Drag & drop with preview gallery
- **Progress Tracking**: Real-time upload status
- **Spacebar / Arrow Right**: Manually advance to next image
- **Slideshow Engine**: Fullscreen presentation with automatic progression
- **Responsive Design**: Mobile and desktop optimized
- **Home Button**: Return to main upload interface
### Storage Architecture
```
Docker Volume (app-data)
├── upload/
│ ├── ZMmHXzHbqw.jpg
│ ├── tjjnngOmXS.jpg
│ └── ...### Slideshow JSON Format
└── data/ # Metadata
└── upload-groups.json
```
### Hosting it with Docker
- **Frontend**: React 17, Material-UI, React Router
- **Backend**: Node.js, Express, Multer (file handling)
- **Containerization**: Docker, Docker Compose
- **Reverse Proxy**: nginx (routing & file serving)[In order to host the project you will need to create a docker-compose file. These files are combining multiple docker images to interact with each other.
- **File Upload**: Drag & drop with react-dropzone
- **Notifications**: SweetAlert2
## API Endpoints
### Upload Operations
- `POST /api/upload/batch` - Upload multiple images with description
- `GET /api/groups` - Retrieve all slideshow groups
- `GET /api/groups/:id` - Get specific slideshow group
### Moderation Operations (Protected)
- `GET /moderation/groups` - Get all groups pending moderation
- `POST /groups/:id/approve` - Approve a group for public display
- `DELETE /groups/:id` - Delete an entire group
- `DELETE /groups/:id/images/:imageId` - Delete individual image from group
### File Access
- `GET /api/upload/:filename` - Access uploaded image files
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `API_URL` | `http://localhost:5000` | Backend API endpoint |
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
### Volume Configuration
- **Data Persistence**: `/usr/src/app/src/upload` and `/usr/src/app/src/data` mounted to `app-data`
- **Upload Limits**: 100MB maximum file size for batch uploads
- **Supported Formats**: JPG, JPEG, PNG, GIF, WebP
### Custom Deployment
For production deployment, modify the docker-compose configuration:
```yaml
environment:
- "API_URL=https://your-domain.com/api"
- "CLIENT_URL=https://your-domain.com"
```
### Backup & Restore
#### Backup slideshow data
```sh
docker cp backend:/usr/src/app/src/data ./backup-data
docker cp backend:/usr/src/app/src/upload ./backup-images
```
#### Restore slideshow data
```sh
docker cp ./backup-data backend:/usr/src/app/src/data
docker cp ./backup-images backend:/usr/src/app/src/upload
```
## Contributing
Contributions are welcome!
This project extends the original work by [vallezw](https://github.com/vallezw/Image-Uploader).
### Development Setup
1. Fork the repository
2. Create feature branch: `git checkout -b feature/amazing-feature`
3. Commit changes: `git commit -m 'Add amazing feature'`
4. Push to branch: `git push origin feature/amazing-feature`| Field | Type | Description |#### Changing the URL
5. Open a Pull Request
## License
This project is distributed under the MIT License. See `LICENSE` for more information.
## Acknowledgments
- Original project: [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader)
Mit dieser Webapp kann der Nutzer von offenen Werkstätten die Bilder von seinem Projekt hochladen und eine Kurze Beschreibung ergänzen.

3
backend/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
npm-debug.log
upload/

1
backend/.env.example Normal file
View File

@ -0,0 +1 @@
REMOVE_IMAGES=<boolean | undefined>

41
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Image safe point
/upload/*
/src/upload/*
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://docs.npmjs.com/cli/shrinkwrap#caveats
node_modules
# Debug log from npm
npm-debug.log
.DS_Store
package-lock.json
.env

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:14
WORKDIR /usr/src/app
COPY package*.json ./
# Development
RUN npm install
# Production
# RUN npm ci --only=production
COPY . .
EXPOSE 5000
CMD [ "node", "src/index.js" ]

30
backend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"server": "nodemon src/index.js",
"client": "npm run dev --prefix ../frontend",
"client-build": "cd ../frontend && npm run build && serve -s build -l 80",
"dev": "concurrently \"npm run server\" \"npm run client\"",
"build": "concurrently \"npm run server\" \"npm run client-build\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"find-remove": "^2.0.3",
"fs": "^0.0.1-security",
"shortid": "^2.2.16",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"concurrently": "^6.0.0",
"nodemon": "^2.0.7"
}
}

16
backend/src/constants.js Normal file
View File

@ -0,0 +1,16 @@
const endpoints = {
UPLOAD_STATIC_DIRECTORY: '/upload',
UPLOAD_FILE: '/upload',
UPLOAD_BATCH: '/upload/batch',
DOWNLOAD_FILE: '/download/:id',
GET_GROUP: '/groups/:groupId',
GET_ALL_GROUPS: '/groups',
DELETE_GROUP: '/groups/:groupId'
};
const time = {
HOURS_24: 86400000,
WEEK_1: 604800000
};
module.exports = { endpoints, time };

View File

@ -0,0 +1,202 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
class DatabaseManager {
constructor() {
this.db = null;
this.dbPath = path.join(__dirname, '../data/image_uploader.db'); // FIX: ../data statt ../../data
this.schemaPath = path.join(__dirname, 'schema.sql');
}
async initialize() {
try {
// Stelle sicher, dass das data-Verzeichnis existiert
const dataDir = path.dirname(this.dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Öffne Datenbankverbindung
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('Fehler beim Öffnen der Datenbank:', err.message);
throw err;
} else {
console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
}
});
// Aktiviere Foreign Keys
await this.run('PRAGMA foreign_keys = ON');
// Erstelle Schema
await this.createSchema();
console.log('✓ Datenbank erfolgreich initialisiert');
} catch (error) {
console.error('Fehler bei Datenbank-Initialisierung:', error);
throw error;
}
}
async createSchema() {
try {
console.log('🔨 Erstelle Datenbank-Schema...');
// Erstelle Groups Tabelle
await this.run(`
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT UNIQUE NOT NULL,
year INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
name TEXT,
upload_date DATETIME NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Migration: Füge approved Feld zu bestehenden Tabellen hinzu (falls nicht vorhanden)
try {
await this.run('ALTER TABLE groups ADD COLUMN approved BOOLEAN DEFAULT FALSE');
console.log('✓ Approved Feld zur bestehenden Tabelle hinzugefügt');
} catch (error) {
// Feld existiert bereits - das ist okay
if (!error.message.includes('duplicate column')) {
console.warn('Migration Warnung:', error.message);
}
}
console.log('✓ Groups Tabelle erstellt');
// Erstelle Images Tabelle
await this.run(`
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
upload_order INTEGER NOT NULL,
file_size INTEGER,
mime_type TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE
)
`);
console.log('✓ Images Tabelle erstellt');
// Erstelle Indizes
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id)');
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_year ON groups(year)');
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_upload_date ON groups(upload_date)');
await this.run('CREATE INDEX IF NOT EXISTS idx_images_group_id ON images(group_id)');
await this.run('CREATE INDEX IF NOT EXISTS idx_images_upload_order ON images(upload_order)');
console.log('✓ Indizes erstellt');
// Erstelle Trigger
await this.run(`
CREATE TRIGGER IF NOT EXISTS update_groups_timestamp
AFTER UPDATE ON groups
FOR EACH ROW
BEGIN
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
console.log('✓ Trigger erstellt');
console.log('✅ Datenbank-Schema vollständig erstellt');
} catch (error) {
console.error('💥 Fehler beim Erstellen des Schemas:', error);
throw error;
}
}
// Promise-wrapper für sqlite3.run
run(sql, params = []) {
return new Promise((resolve, reject) => {
this.db.run(sql, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, changes: this.changes });
}
});
});
}
// Promise-wrapper für sqlite3.get
get(sql, params = []) {
return new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
// Promise-wrapper für sqlite3.all
all(sql, params = []) {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
// Transaction support
async transaction(callback) {
await this.run('BEGIN TRANSACTION');
try {
const result = await callback(this);
await this.run('COMMIT');
return result;
} catch (error) {
await this.run('ROLLBACK');
throw error;
}
}
close() {
return new Promise((resolve, reject) => {
if (this.db) {
this.db.close((err) => {
if (err) {
reject(err);
} else {
console.log('✓ Datenbankverbindung geschlossen');
resolve();
}
});
} else {
resolve();
}
});
}
// Gesundheitscheck
async healthCheck() {
try {
const result = await this.get('SELECT 1 as test');
return result && result.test === 1;
} catch (error) {
console.error('Database health check failed:', error);
return false;
}
}
}
// Singleton Instance
const dbManager = new DatabaseManager();
module.exports = dbManager;

View File

@ -0,0 +1,48 @@
-- Image Uploader SQLite Schema
-- Migration von JSON zu SQLite für bessere Performance
-- Groups Tabelle für Upload-Gruppen
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT UNIQUE NOT NULL,
year INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
name TEXT,
upload_date DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Images Tabelle für einzelne Bilder
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
upload_order INTEGER NOT NULL,
file_size INTEGER,
mime_type TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE
);
-- Indizes für bessere Performance
CREATE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id);
CREATE INDEX IF NOT EXISTS idx_groups_year ON groups(year);
CREATE INDEX IF NOT EXISTS idx_groups_upload_date ON groups(upload_date);
CREATE INDEX IF NOT EXISTS idx_images_group_id ON images(group_id);
CREATE INDEX IF NOT EXISTS idx_images_upload_order ON images(upload_order);
-- Trigger für updated_at
CREATE TRIGGER IF NOT EXISTS update_groups_timestamp
AFTER UPDATE ON groups
FOR EACH ROW
BEGIN
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

9
backend/src/index.js Normal file
View File

@ -0,0 +1,9 @@
require('./utils/load-env')
const Server = require('./server');
// Start server mit async/await Support
(async () => {
const server = new Server(process.env.PORT || 5000);
await server.start();
})();

View File

@ -0,0 +1,13 @@
const cors = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.get('Origin') || '*');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
res.header('Access-Control-Expose-Headers', 'Content-Length');
res.header('Access-Control-Allow-Headers', 'Accept, Authorization, Content-Type, X-Requested-With, Range');
if (req.method === 'OPTIONS') return res.send(200);
return next();
};
module.exports = cors;

View File

@ -0,0 +1,12 @@
const express = require("express");
const fileUpload = require("express-fileupload");
const cors = require("./cors");
const applyMiddlewares = (app) => {
app.use(fileUpload());
app.use(cors);
// JSON Parser für PATCH/POST Requests
app.use(express.json());
};
module.exports = { applyMiddlewares };

View File

@ -0,0 +1,52 @@
const generateId = require("shortid");
class UploadGroup {
constructor(metadata = {}) {
this.groupId = generateId();
// Strukturierte Metadaten
this.year = metadata.year || new Date().getFullYear();
this.title = metadata.title || "";
this.description = metadata.description || "";
this.name = metadata.name || "";
// Backwards compatibility
if (typeof metadata === 'string') {
this.description = metadata;
this.year = new Date().getFullYear();
this.title = "";
this.name = "";
}
this.uploadDate = new Date().toISOString();
this.images = [];
}
addImage(fileName, originalName, uploadOrder) {
this.images.push({
fileName,
originalName,
filePath: `/upload/${fileName}`,
uploadOrder: uploadOrder || this.images.length + 1
});
}
getImageCount() {
return this.images.length;
}
toJSON() {
return {
groupId: this.groupId,
year: this.year,
title: this.title,
description: this.description,
name: this.name,
uploadDate: this.uploadDate,
images: this.images,
imageCount: this.getImageCount()
};
}
}
module.exports = UploadGroup;

View File

@ -0,0 +1,340 @@
const dbManager = require('../database/DatabaseManager');
class GroupRepository {
// Erstelle neue Gruppe mit Bildern (Transaction)
async createGroup(groupData) {
return await dbManager.transaction(async (db) => {
// Füge Gruppe hinzu
const groupResult = await db.run(`
INSERT INTO groups (group_id, year, title, description, name, upload_date)
VALUES (?, ?, ?, ?, ?, ?)
`, [
groupData.groupId,
groupData.year,
groupData.title,
groupData.description || null,
groupData.name || null,
groupData.uploadDate
]);
// Füge Bilder hinzu
if (groupData.images && groupData.images.length > 0) {
for (const image of groupData.images) {
await db.run(`
INSERT INTO images (group_id, file_name, original_name, file_path, upload_order, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
groupData.groupId,
image.fileName,
image.originalName,
image.filePath,
image.uploadOrder,
image.fileSize || null,
image.mimeType || null
]);
}
}
return groupResult.id;
});
}
// Hole Gruppe mit Bildern nach Group-ID
async getGroupById(groupId) {
const group = await dbManager.get(`
SELECT * FROM groups WHERE group_id = ?
`, [groupId]);
if (!group) {
return null;
}
const images = await dbManager.all(`
SELECT * FROM images
WHERE group_id = ?
ORDER BY upload_order ASC
`, [groupId]);
return {
groupId: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
images: images.map(img => ({
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order,
fileSize: img.file_size,
mimeType: img.mime_type
})),
imageCount: images.length
};
}
// Hole alle Gruppen (mit Paginierung)
async getAllGroups(limit = null, offset = 0) {
let sql = `
SELECT g.*, COUNT(i.id) as image_count
FROM groups g
LEFT JOIN images i ON g.group_id = i.group_id
GROUP BY g.group_id
ORDER BY g.upload_date DESC
`;
const params = [];
if (limit) {
sql += ` LIMIT ? OFFSET ?`;
params.push(limit, offset);
}
const groups = await dbManager.all(sql, params);
return {
groups: groups.map(group => ({
groupId: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
imageCount: group.image_count
})),
total: groups.length
};
}
// Hole alle Gruppen mit Bildern für Slideshow (nur freigegebene)
async getAllGroupsWithImages() {
const groups = await dbManager.all(`
SELECT * FROM groups
WHERE approved = TRUE
ORDER BY upload_date DESC
`);
const result = [];
for (const group of groups) {
const images = await dbManager.all(`
SELECT * FROM images
WHERE group_id = ?
ORDER BY upload_order ASC
`, [group.group_id]);
result.push({
groupId: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
images: images.map(img => ({
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order
})),
imageCount: images.length
});
}
return result;
}
// Lösche Gruppe und alle Bilder
async deleteGroup(groupId) {
return await dbManager.transaction(async (db) => {
// Erst alle Bilddateien physisch löschen
const images = await db.all(`
SELECT * FROM images WHERE group_id = ?
`, [groupId]);
const fs = require('fs').promises;
const path = require('path');
for (const image of images) {
try {
const absolutePath = path.join(__dirname, '..', image.file_path);
await fs.unlink(absolutePath);
console.log(`✓ Bilddatei gelöscht: ${absolutePath}`);
} catch (error) {
console.warn(`⚠️ Konnte Bilddatei nicht löschen: ${image.file_path}`, error.message);
}
}
// Dann Gruppe aus Datenbank löschen (Bilder werden durch CASCADE gelöscht)
const result = await db.run(`
DELETE FROM groups WHERE group_id = ?
`, [groupId]);
console.log(`✓ Gruppe gelöscht: ${groupId} (${images.length} Bilder)`);
return result.changes > 0;
});
}
// Update Gruppe
async updateGroup(groupId, updates) {
const setClause = [];
const params = [];
if (updates.year !== undefined) {
setClause.push('year = ?');
params.push(updates.year);
}
if (updates.title !== undefined) {
setClause.push('title = ?');
params.push(updates.title);
}
if (updates.description !== undefined) {
setClause.push('description = ?');
params.push(updates.description);
}
if (updates.name !== undefined) {
setClause.push('name = ?');
params.push(updates.name);
}
if (setClause.length === 0) {
return false;
}
params.push(groupId);
const result = await dbManager.run(`
UPDATE groups SET ${setClause.join(', ')} WHERE group_id = ?
`, params);
return result.changes > 0;
}
// Gruppe Freigabe-Status aktualisieren
async updateGroupApproval(groupId, approved) {
const result = await dbManager.run(`
UPDATE groups SET approved = ? WHERE group_id = ?
`, [approved, groupId]);
return result.changes > 0;
}
// Einzelnes Bild löschen
async deleteImage(groupId, imageId) {
return await dbManager.transaction(async (db) => {
// Prüfe ob Bild existiert
const image = await db.get(`
SELECT * FROM images WHERE id = ? AND group_id = ?
`, [imageId, groupId]);
if (!image) {
return false;
}
// Lösche Datei vom Dateisystem
const fs = require('fs').promises;
const path = require('path');
try {
// Konvertiere relativen Pfad zu absolutem Pfad im Container
// image.file_path ist "/upload/dateiname.ext", wir brauchen "/usr/src/app/src/upload/dateiname.ext"
const absolutePath = path.join(__dirname, '..', image.file_path);
await fs.unlink(absolutePath);
console.log(`✓ Bilddatei gelöscht: ${absolutePath}`);
} catch (error) {
console.warn(`⚠️ Konnte Bilddatei nicht löschen: ${image.file_path}`, error.message);
// Datei-Löschfehler sollen nicht das Löschen aus der Datenbank verhindern
}
// Lösche aus Datenbank
const result = await db.run(`
DELETE FROM images WHERE id = ? AND group_id = ?
`, [imageId, groupId]);
// Aktualisiere upload_order der verbleibenden Bilder
await db.run(`
UPDATE images
SET upload_order = upload_order - 1
WHERE group_id = ? AND upload_order > ?
`, [groupId, image.upload_order]);
return result.changes > 0;
});
}
// Alle Gruppen für Moderation (mit Freigabestatus und Bildanzahl)
async getAllGroupsWithModerationInfo() {
const groups = await dbManager.all(`
SELECT
g.*,
COUNT(i.id) as image_count,
MIN(i.file_path) as preview_image
FROM groups g
LEFT JOIN images i ON g.group_id = i.group_id
GROUP BY g.group_id
ORDER BY g.approved ASC, g.upload_date DESC
`);
return groups.map(group => ({
...group,
approved: Boolean(group.approved),
image_count: group.image_count || 0
}));
}
// Hole Gruppe für Moderation (inkl. nicht-freigegebene)
async getGroupForModeration(groupId) {
const group = await dbManager.get(`
SELECT * FROM groups WHERE group_id = ?
`, [groupId]);
if (!group) {
return null;
}
const images = await dbManager.all(`
SELECT * FROM images
WHERE group_id = ?
ORDER BY upload_order ASC
`, [groupId]);
return {
group_id: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
approved: group.approved,
images: images.map(img => ({
id: img.id,
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order,
fileSize: img.file_size,
mimeType: img.mime_type
})),
imageCount: images.length
};
}
// Statistiken (erweitert um Freigabe-Status)
async getStats() {
const groupCount = await dbManager.get('SELECT COUNT(*) as count FROM groups');
const imageCount = await dbManager.get('SELECT COUNT(*) as count FROM images');
const approvedGroups = await dbManager.get('SELECT COUNT(*) as count FROM groups WHERE approved = TRUE');
const pendingGroups = await dbManager.get('SELECT COUNT(*) as count FROM groups WHERE approved = FALSE');
const latestGroup = await dbManager.get(`
SELECT upload_date FROM groups ORDER BY upload_date DESC LIMIT 1
`);
return {
totalGroups: groupCount.count,
totalImages: imageCount.count,
approvedGroups: approvedGroups.count,
pendingGroups: pendingGroups.count,
latestUpload: latestGroup ? latestGroup.upload_date : null
};
}
}
module.exports = new GroupRepository();

View File

@ -0,0 +1,108 @@
const generateId = require("shortid");
const express = require('express');
const { Router } = require('express');
const { endpoints } = require('../constants');
const UploadGroup = require('../models/uploadGroup');
const GroupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager');
const router = Router();
// Batch-Upload für mehrere Bilder
router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
try {
// Überprüfe ob Dateien hochgeladen wurden
if (!req.files || !req.files.images) {
return res.status(400).json({
error: 'No images uploaded',
message: 'Keine Bilder wurden hochgeladen'
});
}
// Metadaten aus dem Request body
let metadata = {};
try {
metadata = req.body.metadata ? JSON.parse(req.body.metadata) : {};
} catch (e) {
console.error('Error parsing metadata:', e);
metadata = { description: req.body.description || "" };
}
// Erstelle neue Upload-Gruppe mit erweiterten Metadaten
const group = new UploadGroup(metadata);
// Handle sowohl einzelne Datei als auch Array von Dateien
const files = Array.isArray(req.files.images) ? req.files.images : [req.files.images];
console.log(`Processing ${files.length} files for batch upload`);
// Verarbeite alle Dateien
const processedFiles = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Generiere eindeutigen Dateinamen
const fileEnding = file.name.split(".").pop();
const fileName = generateId() + '.' + fileEnding;
// Speichere Datei
const uploadPath = `${__dirname}/..${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`;
file.mv(uploadPath, (err) => {
if (err) {
console.error('Error saving file:', err);
}
});
// Füge Bild zur Gruppe hinzu
group.addImage(fileName, file.name, i + 1);
processedFiles.push({
fileName,
originalName: file.name,
size: file.size
});
}
// Speichere Gruppe in SQLite
await GroupRepository.createGroup({
groupId: group.groupId,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.uploadDate,
images: processedFiles.map((file, index) => ({
fileName: file.fileName,
originalName: file.originalName,
filePath: `/upload/${file.fileName}`,
uploadOrder: index + 1,
fileSize: file.size,
mimeType: files[index].mimetype
}))
});
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
// Erfolgreiche Antwort
res.json({
groupId: group.groupId,
message: 'Batch upload successful',
imageCount: files.length,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.uploadDate,
files: processedFiles
});
} catch (error) {
console.error('Batch upload error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Ein Fehler ist beim Upload aufgetreten',
details: error.message
});
}
});
module.exports = router;

View File

@ -0,0 +1,10 @@
const { Router } = require('express');
const { endpoints } = require('../constants');
const router = Router();
router.get(endpoints.DOWNLOAD_FILE, (req, res) => {
res.download(`${__dirname}/..${endpoints.UPLOAD_STATIC_DIRECTORY}/${req.params.id}`);
});
module.exports = router;

View File

@ -0,0 +1,199 @@
const { Router } = require('express');
const { endpoints } = require('../constants');
const GroupRepository = require('../repositories/GroupRepository');
const MigrationService = require('../services/MigrationService');
const router = Router();
// Alle Gruppen abrufen (für Slideshow mit vollständigen Bilddaten)
router.get(endpoints.GET_ALL_GROUPS, async (req, res) => {
try {
// Auto-Migration beim ersten Zugriff
const migrationStatus = await MigrationService.getMigrationStatus();
if (migrationStatus.needsMigration) {
console.log('🔄 Starte automatische Migration...');
await MigrationService.migrateJsonToSqlite();
}
const groups = await GroupRepository.getAllGroupsWithImages();
res.json({
groups,
totalCount: groups.length
});
} catch (error) {
console.error('Error fetching all groups:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Gruppen',
details: error.message
});
}
});
// Alle Gruppen für Moderation abrufen (mit Freigabestatus) - MUSS VOR den :groupId routen stehen!
router.get('/moderation/groups', async (req, res) => {
try {
const groups = await GroupRepository.getAllGroupsWithModerationInfo();
res.json({
groups,
totalCount: groups.length,
pendingCount: groups.filter(g => !g.approved).length,
approvedCount: groups.filter(g => g.approved).length
});
} catch (error) {
console.error('Error fetching moderation groups:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Moderations-Gruppen',
details: error.message
});
}
});
// Einzelne Gruppe für Moderation abrufen (inkl. nicht-freigegebene)
router.get('/moderation/groups/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const group = await GroupRepository.getGroupForModeration(groupId);
if (!group) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json(group);
} catch (error) {
console.error('Error fetching group for moderation:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Gruppe für Moderation',
details: error.message
});
}
});
// Einzelne Gruppe abrufen
router.get(endpoints.GET_GROUP, async (req, res) => {
try {
const { groupId } = req.params;
const group = await GroupRepository.getGroupById(groupId);
if (!group) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json(group);
} catch (error) {
console.error('Error fetching group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Gruppe',
details: error.message
});
}
});
// Gruppe freigeben/genehmigen
router.patch('/groups/:groupId/approve', async (req, res) => {
try {
const { groupId } = req.params;
const { approved } = req.body;
// Validierung
if (typeof approved !== 'boolean') {
return res.status(400).json({
error: 'Invalid request',
message: 'approved muss ein boolean Wert sein'
});
}
const updated = await GroupRepository.updateGroupApproval(groupId, approved);
if (!updated) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt',
groupId: groupId,
approved: approved
});
} catch (error) {
console.error('Error updating group approval:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Freigabe'
});
}
});
// Einzelnes Bild löschen
router.delete('/groups/:groupId/images/:imageId', async (req, res) => {
try {
const { groupId, imageId } = req.params;
const deleted = await GroupRepository.deleteImage(groupId, parseInt(imageId));
if (!deleted) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bild erfolgreich gelöscht',
groupId: groupId,
imageId: parseInt(imageId)
});
} catch (error) {
console.error('Error deleting image:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen des Bildes'
});
}
});
// Gruppe löschen
router.delete(endpoints.DELETE_GROUP, async (req, res) => {
try {
const { groupId } = req.params;
const deleted = await GroupRepository.deleteGroup(groupId);
if (!deleted) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Gruppe erfolgreich gelöscht',
groupId: groupId
});
} catch (error) {
console.error('Error deleting group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen der Gruppe'
});
}
});
module.exports = router;

View File

@ -0,0 +1,11 @@
const uploadRouter = require('./upload');
const downloadRouter = require('./download');
const batchUploadRouter = require('./batchUpload');
const groupsRouter = require('./groups');
const migrationRouter = require('./migration');
const renderRoutes = (app) => {
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter].forEach(router => app.use('/', router));
};
module.exports = { renderRoutes };

View File

@ -0,0 +1,75 @@
const express = require('express');
const { Router } = require('express');
const MigrationService = require('../services/MigrationService');
const dbManager = require('../database/DatabaseManager');
const router = Router();
// Migration Status abrufen
router.get('/migration/status', async (req, res) => {
try {
const status = await MigrationService.getMigrationStatus();
res.json(status);
} catch (error) {
console.error('Fehler beim Abrufen des Migrationsstatus:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Abrufen des Migrationsstatus',
details: error.message
});
}
});
// Manuelle Migration starten
router.post('/migration/migrate', async (req, res) => {
try {
const result = await MigrationService.migrateJsonToSqlite();
res.json(result);
} catch (error) {
console.error('Fehler bei der Migration:', error);
res.status(500).json({
error: 'Migration failed',
message: 'Fehler bei der Migration',
details: error.message
});
}
});
// Rollback zu JSON (Notfall)
router.post('/migration/rollback', async (req, res) => {
try {
const result = await MigrationService.rollbackToJson();
res.json(result);
} catch (error) {
console.error('Fehler beim Rollback:', error);
res.status(500).json({
error: 'Rollback failed',
message: 'Fehler beim Rollback',
details: error.message
});
}
});
// Datenbank Health Check
router.get('/migration/health', async (req, res) => {
try {
const isHealthy = await dbManager.healthCheck();
res.json({
database: {
healthy: isHealthy,
status: isHealthy ? 'OK' : 'ERROR'
}
});
} catch (error) {
console.error('Health Check fehlgeschlagen:', error);
res.status(500).json({
database: {
healthy: false,
status: 'ERROR',
error: error.message
}
});
}
});
module.exports = router;

View File

@ -0,0 +1,32 @@
const generateId = require("shortid");
const express = require('express');
const { Router } = require('express');
const { endpoints } = require('../constants');
const router = Router();
router.use(endpoints.UPLOAD_STATIC_DIRECTORY, express.static( __dirname + endpoints.UPLOAD_STATIC_DIRECTORY));
router.post(endpoints.UPLOAD_FILE, (req, res) => {
if(req.files === null){
console.log('No file uploaded');
return res.status(400).json({ msg: 'No file uploaded' });
}
const file = req.files.file;
fileEnding = file.name.split(".")
fileEnding = fileEnding[fileEnding.length - 1]
fileName = generateId() + '.' + fileEnding
file.mv(`${__dirname}/..` + endpoints.UPLOAD_STATIC_DIRECTORY + `/${fileName}`, err => {
if(err) {
console.error(err);
return res.status(500).send(err);
}
res.json({ filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`});
});
});
module.exports = router;

35
backend/src/server.js Normal file
View File

@ -0,0 +1,35 @@
const express = require('express');
const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager');
class Server {
_port;
_app;
constructor(port) {
this._port = port;
this._app = express();
}
async start() {
try {
// Initialisiere Datenbank
console.log('🔄 Initialisiere Datenbank...');
await dbManager.initialize();
console.log('✓ Datenbank bereit');
// Starte Express Server
initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload'));
this._app.listen(this._port, () => {
console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`);
});
} catch (error) {
console.error('💥 Fehler beim Serverstart:', error);
process.exit(1);
}
}
}
module.exports = Server;

View File

@ -0,0 +1,207 @@
const fs = require('fs').promises;
const path = require('path');
const GroupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager');
class MigrationService {
constructor() {
this.jsonDataPath = path.join(__dirname, '../data');
this.backupPath = path.join(__dirname, '../data/backup');
}
// Hauptmigration von JSON zu SQLite
async migrateJsonToSqlite() {
console.log('🔄 Starte Migration von JSON zu SQLite...');
try {
// 1. Initialisiere Datenbank
await dbManager.initialize();
// 2. Prüfe ob bereits migriert
const stats = await GroupRepository.getStats();
if (stats.totalGroups > 0) {
console.log(` Datenbank enthält bereits ${stats.totalGroups} Gruppen. Migration wird übersprungen.`);
return { success: true, message: 'Already migrated', stats };
}
// 3. Lade JSON-Gruppen
const jsonGroups = await this.loadJsonGroups();
if (jsonGroups.length === 0) {
console.log(' Keine JSON-Gruppen zum Migrieren gefunden.');
return { success: true, message: 'No JSON data found', migrated: 0 };
}
// 4. Erstelle Backup
await this.createBackup();
// 5. Migriere Gruppen
let migratedCount = 0;
let errorCount = 0;
for (const jsonGroup of jsonGroups) {
try {
await this.migrateGroup(jsonGroup);
migratedCount++;
console.log(`✓ Gruppe ${jsonGroup.groupId} migriert`);
} catch (error) {
errorCount++;
console.error(`✗ Fehler bei Gruppe ${jsonGroup.groupId}:`, error.message);
}
}
console.log(`🎉 Migration abgeschlossen: ${migratedCount} erfolgreich, ${errorCount} Fehler`);
return {
success: true,
migrated: migratedCount,
errors: errorCount,
total: jsonGroups.length
};
} catch (error) {
console.error('💥 Migration fehlgeschlagen:', error);
throw error;
}
}
// Lade alle JSON-Gruppendateien
async loadJsonGroups() {
const groups = [];
try {
const files = await fs.readdir(this.jsonDataPath);
const jsonFiles = files.filter(file => file.endsWith('.json') && file !== 'groups.json');
for (const file of jsonFiles) {
try {
const filePath = path.join(this.jsonDataPath, file);
const content = await fs.readFile(filePath, 'utf8');
const group = JSON.parse(content);
// Validiere Gruppenstruktur
if (this.validateGroup(group)) {
groups.push(this.normalizeGroup(group));
} else {
console.warn(`⚠️ Ungültige Gruppenstruktur in ${file}`);
}
} catch (error) {
console.error(`✗ Fehler beim Laden von ${file}:`, error.message);
}
}
} catch (error) {
console.error('Fehler beim Lesen des data-Verzeichnisses:', error);
}
return groups;
}
// Validiere JSON-Gruppenstruktur
validateGroup(group) {
return group &&
group.groupId &&
group.uploadDate &&
Array.isArray(group.images);
}
// Normalisiere Gruppendaten für SQLite
normalizeGroup(jsonGroup) {
return {
groupId: jsonGroup.groupId,
year: jsonGroup.year || new Date(jsonGroup.uploadDate).getFullYear(),
title: jsonGroup.title || 'Migriertes Projekt',
description: jsonGroup.description || '',
name: jsonGroup.name || '',
uploadDate: jsonGroup.uploadDate,
images: jsonGroup.images.map((img, index) => ({
fileName: img.fileName,
originalName: img.originalName,
filePath: img.filePath,
uploadOrder: img.uploadOrder || index + 1,
fileSize: img.fileSize || null,
mimeType: img.mimeType || null
}))
};
}
// Migriere einzelne Gruppe
async migrateGroup(group) {
await GroupRepository.createGroup(group);
}
// Erstelle Backup der JSON-Dateien
async createBackup() {
try {
// Erstelle backup-Verzeichnis
await fs.mkdir(this.backupPath, { recursive: true });
const files = await fs.readdir(this.jsonDataPath);
const jsonFiles = files.filter(file => file.endsWith('.json'));
for (const file of jsonFiles) {
const sourcePath = path.join(this.jsonDataPath, file);
const backupPath = path.join(this.backupPath, `${Date.now()}_${file}`);
await fs.copyFile(sourcePath, backupPath);
}
console.log(`✓ Backup erstellt: ${jsonFiles.length} Dateien`);
} catch (error) {
console.error('Fehler beim Erstellen des Backups:', error);
throw error;
}
}
// Rollback zur JSON-Struktur (falls nötig)
async rollbackToJson() {
console.log('🔄 Starte Rollback zu JSON...');
try {
const groups = await GroupRepository.getAllGroupsWithImages();
for (const group of groups) {
const fileName = `${group.groupId}.json`;
const filePath = path.join(this.jsonDataPath, fileName);
await fs.writeFile(filePath, JSON.stringify(group, null, 2));
}
console.log(`✓ Rollback abgeschlossen: ${groups.length} Gruppen`);
return { success: true, exported: groups.length };
} catch (error) {
console.error('Rollback fehlgeschlagen:', error);
throw error;
}
}
// Migrationsstatus prüfen
async getMigrationStatus() {
try {
const dbStats = await GroupRepository.getStats();
// Zähle JSON-Dateien
const files = await fs.readdir(this.jsonDataPath);
const jsonFileCount = files.filter(file => file.endsWith('.json') && file !== 'groups.json').length;
return {
database: {
initialized: dbStats.totalGroups >= 0,
groups: dbStats.totalGroups,
images: dbStats.totalImages,
latestUpload: dbStats.latestUpload
},
json: {
files: jsonFileCount
},
migrated: dbStats.totalGroups > 0,
needsMigration: jsonFileCount > 0 && dbStats.totalGroups === 0
};
} catch (error) {
return {
database: { initialized: false, error: error.message },
json: { files: 0 },
migrated: false,
needsMigration: false
};
}
}
}
module.exports = new MigrationService();

View File

@ -0,0 +1,71 @@
const fs = require('fs');
const path = require('path');
const GROUPS_FILE = path.join(__dirname, '../data/upload-groups.json');
class GroupStorage {
// Initialisiere die JSON-Datei falls sie nicht existiert
static ensureDataFile() {
const dataDir = path.dirname(GROUPS_FILE);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
if (!fs.existsSync(GROUPS_FILE)) {
fs.writeFileSync(GROUPS_FILE, JSON.stringify([], null, 2));
}
}
// Alle Gruppen aus der JSON-Datei lesen
static getAllGroups() {
this.ensureDataFile();
try {
const data = fs.readFileSync(GROUPS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('Error reading groups file:', error);
return [];
}
}
// Einzelne Gruppe per ID finden
static getGroup(groupId) {
const groups = this.getAllGroups();
return groups.find(group => group.groupId === groupId);
}
// Neue Gruppe speichern
static saveGroup(group) {
this.ensureDataFile();
try {
const groups = this.getAllGroups();
groups.push(group.toJSON());
fs.writeFileSync(GROUPS_FILE, JSON.stringify(groups, null, 2));
return true;
} catch (error) {
console.error('Error saving group:', error);
return false;
}
}
// Gruppe löschen
static deleteGroup(groupId) {
try {
const groups = this.getAllGroups();
const filteredGroups = groups.filter(group => group.groupId !== groupId);
fs.writeFileSync(GROUPS_FILE, JSON.stringify(filteredGroups, null, 2));
return true;
} catch (error) {
console.error('Error deleting group:', error);
return false;
}
}
// Gruppen nach Datum sortiert abrufen (neueste zuerst)
static getGroupsSorted() {
const groups = this.getAllGroups();
return groups.sort((a, b) => new Date(b.uploadDate) - new Date(a.uploadDate));
}
}
module.exports = GroupStorage;

View File

@ -0,0 +1,24 @@
const { applyMiddlewares } = require('../middlewares');
const { renderRoutes } = require('../routes/index');
const removeImages = require('./remove-images');
const fs = require('fs');
const { endpoints } = require('../constants');
const initiateResources = (app) => {
applyMiddlewares(app);
renderRoutes(app);
const dir = `${__dirname}/..` + endpoints.UPLOAD_STATIC_DIRECTORY
if (!fs.existsSync(dir)){
fs.mkdirSync(dir);
}
if(process.env.REMOVE_IMAGES === 'true') {
removeImages();
};
};
module.exports = initiateResources;

View File

@ -0,0 +1,3 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../.env')});

View File

@ -0,0 +1,10 @@
const findRemove = require('find-remove');
const { time } = require('../constants');
const removeImages = () => {
setInterval(findRemove.bind(this, __dirname + '/upload', {
age: {seconds: time.WEEK_1 / 1000 }, extensions: ['.jpg', '.jpeg', '.png', '.gif']
}), time.HOURS_24);
};
module.exports = removeImages;

41
docker-compose.yml Normal file
View File

@ -0,0 +1,41 @@
services:
image-uploader-frontend:
image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-frontend:latest
ports:
- "80:80"
build:
context: ./frontend
dockerfile: ./Dockerfile
depends_on:
- "image-uploader-backend"
environment:
- "API_URL=http://image-uploader-backend:5000"
- "CLIENT_URL=http://localhost"
container_name: "image-uploader-frontend"
networks:
- npm-nw
- image-uploader-internal
image-uploader-backend:
image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-backend:latest
ports:
- "5000:5000"
build:
context: ./backend
dockerfile: ./Dockerfile
container_name: "image-uploader-backend"
networks:
- image-uploader-internal
volumes:
- app-data:/usr/src/app/src/upload
- app-data:/usr/src/app/src/data
volumes:
app-data:
driver: local
networks:
npm-nw:
external: true
image-uploader-internal:
driver: bridge

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

BIN
docs/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

2
frontend/.env Normal file
View File

@ -0,0 +1,2 @@
API_URL=http://localhost
CLIENT_URL=http://localhost

25
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/public/env-config.js
/env-config.js

38
frontend/Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# => Build container
FROM node:18-alpine as build
WORKDIR /app
COPY package.json ./
RUN npm install --silent
COPY . ./
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN npm run build
# => Run container
FROM nginx:stable-alpine
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY conf /etc/nginx
# Copy htpasswd file for authentication
COPY htpasswd /etc/nginx/.htpasswd
# Static build
COPY --from=build /app/build /usr/share/nginx/html
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY ./env.sh ./
COPY ./.env ./
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]

70
frontend/README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@ -0,0 +1,106 @@
server {
listen 80;
# Allow large uploads (50MB)
client_max_body_size 50M;
# API proxy to image-uploader-backend service
location /upload {
proxy_pass http://image-uploader-backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for API too
client_max_body_size 50M;
}
# API routes for new multi-upload features
location /api/upload {
proxy_pass http://image-uploader-backend:5000/upload;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for batch upload
client_max_body_size 100M;
}
# API - Groups (NO PASSWORD PROTECTION)
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Protected API - Moderation API routes (password protected) - must come before /groups
location /moderation/groups {
auth_basic "Restricted Area - Moderation API";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://image-uploader-backend:5000/moderation/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups API routes (NO PASSWORD PROTECTION)
location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ {
proxy_pass http://image-uploader-backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /download {
proxy_pass http://image-uploader-backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend page - Groups overview (NO PASSWORD PROTECTION)
location /groups {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
expires -1;
# Prevent indexing
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
}
# Protected routes - Moderation (password protected)
location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
expires -1;
# Prevent indexing
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
}
# Frontend files
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
expires -1; # Set it to different value depending on your standard requirements
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -0,0 +1,24 @@
gzip on;
gzip_http_version 1.0;
gzip_comp_level 5; # 1-9
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
# MIME-types
gzip_types
application/atom+xml
application/javascript
application/json
application/rss+xml
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/svg+xml
image/x-icon
text/css
text/plain
text/x-component;

4
frontend/env-config 2.js Normal file
View File

@ -0,0 +1,4 @@
window._env_ = {
API_URL: "http://localhost:5000",
CLIENT_URL: "http://localhost",
}

29
frontend/env.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Recreate config file
rm -rf ./env-config.js
touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
while read -r line || [[ -n "$line" ]];
do
# Split env variables by character `=`
if printf '%s\n' "$line" | grep -q -e '='; then
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
fi
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
echo "}" >> ./env-config.js

1
frontend/htpasswd Normal file
View File

@ -0,0 +1 @@
admin:$apr1$q2zv8h0V$ueMqnKIeQU6NN1YnHWNVe/

33428
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.21.1",
"react": "^17.0.1",
"react-code-blocks": "^0.0.8",
"react-dom": "^17.0.1",
"react-dropzone": "^11.3.1",
"react-helmet": "^6.1.0",
"react-lottie": "^1.2.3",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"sass": "^1.32.8",
"sweetalert2": "^10.15.6",
"web-vitals": "^1.1.0"
},
"scripts": {
"dev": "chmod +x ./env.sh && ./env.sh && cp env-config.js ./public/ && react-scripts start",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Favicons: multiple sizes for different platforms -->
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/logo-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/logo-32.png" />
<link rel="icon" type="image/png" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/logo-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/logo-512.png" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#323377" />
<meta
name="description"
content="Share your images smoothly over the internet."
/>
<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">
<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">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Image Uploader</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script src="sweetalert2/dist/sweetalert2.min.js"></script>
<script src="%PUBLIC_URL%/env-config.js"></script>
<div id="root"></div>
</body>
</html>

BIN
frontend/public/logo-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "ProjectImageUploader",
"name": "Project Image Uploader",
"icons": [
{
"src": "favicon.ico",
"sizes": "16x16 32x32 48x48",
"type": "image/x-icon"
},
{
"src": "logo-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#323377",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,6 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /groups
Disallow: /moderation
Disallow: /api/groups
Disallow: /moderation/groups

61
frontend/src/App.css Normal file
View File

@ -0,0 +1,61 @@
.cardContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.loading {
margin-top: 20vh;
display: block;
margin-left: auto;
margin-right: auto;
width: 43%;
}
body {
font-family: "Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif;
}
@media screen and (max-height: 750px) {
body {
zoom: 90%;
}
}
@media screen and (max-height: 700px) {
body {
zoom: 85%;
}
}
@media screen and (max-height: 650px) {
body {
zoom: 80%;
}
}
@media screen and (max-height: 600px) {
body {
zoom: 75%;
}
}
@media screen and (max-height: 550px) {
body {
zoom: 70%;
}
}
@media screen and (max-height: 500px) {
body {
zoom: 65%;
}
}
@media screen and (max-height: 450px) {
body {
zoom: 60%;
}
}

27
frontend/src/App.js Normal file
View File

@ -0,0 +1,27 @@
import './App.css';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
// Pages
import UploadedImage from './Components/Pages/UploadedImagePage';
import MultiUploadPage from './Components/Pages/MultiUploadPage';
import SlideshowPage from './Components/Pages/SlideshowPage';
import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage';
import ModerationPage from './Components/Pages/ModerationPage';
import FZF from './Components/Pages/404Page.js'
function App() {
return (
<Router>
<Switch>
<Route path="/" exact component={MultiUploadPage} />
<Route path="/upload/:image_url" component={UploadedImage} />
<Route path="/slideshow" component={SlideshowPage} />
<Route path="/groups" component={GroupsOverviewPage} />
<Route path="/moderation" component={ModerationPage} />
<Route component={FZF} />
</Switch>
</Router>
);
}
export default App;

View File

@ -0,0 +1,23 @@
.allContainer {
/*background: url(../../../Images/background.svg) no-repeat center center fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;*/
background-size: cover;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.allContainerNoBackground {
background-size: cover;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: whitesmoke;
}

View File

@ -0,0 +1,25 @@
.copyright {
text-align:center;
font-size:13px;
color:#aaa;
font-family: "Roboto";
font-weight: lighter;
}
footer {
position: absolute;
bottom: 0;
width: 99%;
height: 2.5rem;
}
a {
text-align:center;
font-size:13px;
color:#aaa;
font-family: "Roboto";
font-weight: lighter;
text-decoration: none;
}

View File

@ -0,0 +1,109 @@
.boxContainer {
width: max-content;
height: max-content;
}
.box{
width: max-content;
height: max-content;
}
/* Style the Image Used to Trigger the Modal */
#myImg {
margin-top: 100px;
border-radius: 5px;
cursor: pointer;
transition: 0.3s;
position: relative;
display: block;
margin-left: auto;
margin-right: auto;
/* Style image size */
width: auto;
height: auto;
max-width: 60vh;
max-height: 60vh;
/* For transparent images: */
background-color: rgb(255, 255, 255, 1);
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); /* Box shadow for the image */
}
#myImg:hover {opacity: 0.7;}
/* The Modal (background) */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
padding-top: 100px; /* Location of the box */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.9); /* Black w/ opacity */
}
/* Modal Content (Image) */
.modal-content {
margin: auto;
display: block;
width: 80%;
max-width: 700px;
/* For transparent images: */
background-color: rgb(255, 255, 255, 1);
}
/* Caption of Modal Image (Image Text) - Same Width as the Image */
#caption {
margin: auto;
display: block;
width: 80%;
max-width: 700px;
text-align: center;
color: #ccc;
padding: 10px 0;
height: 150px;
}
/* Add Animation - Zoom in the Modal */
.modal-content, #caption {
animation-name: zoom;
animation-duration: 0.6s;
}
@keyframes zoom {
from {transform:scale(0)}
to {transform:scale(1)}
}
/* The Close Button */
.close {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
}
.close:hover,
.close:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
/* 100% Image Width on Smaller Screens */
@media only screen and (max-width: 700px){
.modal-content {
width: 100%;
}
}

View File

@ -0,0 +1,48 @@
/*.box {
--border-width: 3px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 300px;
height: 200px;
font-family: Lato, sans-serif;
font-size: 2.5rem;
text-transform: uppercase;
color: white;
background: #222;
border-radius: var(--border-width);
&::after {
position: absolute;
content: "";
top: calc(-1 * var(--border-width));
left: calc(-1 * var(--border-width));
z-index: -1;
width: calc(100% + var(--border-width) * 2);
height: calc(100% + var(--border-width) * 2);
background: linear-gradient(
60deg,
hsl(0, 0%, 0%),
hsl(0, 2%, 38%),
hsl(0, 28%, 60%),
hsl(0, 6%, 85%),
hsl(0, 91%, 40%),
hsl(0, 0%, 79%),
hsl(0, 10%, 80%),
hsl(0, 0%, 0%)
);
background-size: 300% 300%;
background-position: 0 50%;
border-radius: calc(2 * var(--border-width));
animation: moveGradient 4s alternate infinite;
}
}
@keyframes moveGradient {
50% {
background-position: 100% 50%;
}
}
*/

View File

@ -0,0 +1,137 @@
header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 0px 3%;
background-color: rgba(132, 191, 63, 1);
}
.logo {
margin-right: auto;
color: #ECF0F1;
font-family: 'Montserrat', sans-serif;
font-size: 20px;
display: flex;
flex-direction: row;
}
.logo p {
padding-top: 2px;
}
.imageNav {
margin-top: 10px;
height: 50px;
padding-right: 15px;
}
.nav__links {
list-style: none;
display: flex;
}
.nav__links a,
.cta,
.overlay__content a {
font-family: "Montserrat", sans-serif;
font-weight: 500;
color: #edf0f1;
text-decoration: none;
}
.nav__links li {
padding: 0px 20px;
}
.nav__links li a {
transition: all 0.3s ease 0s;
}
.nav__links li a:hover {
color: #0088a9;
}
.cta {
margin-left: 20px;
padding: 9px 25px;
background-color: rgba(0, 136, 169, 1);
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease 0s;
}
.cta:hover {
background-color: rgba(0, 136, 169, 0.8);
}
/* Mobile Nav */
.menu {
display: none;
}
.overlay {
height: 100%;
width: 0;
position: fixed;
z-index: 1;
left: 0;
top: 0;
background-color: #24252a;
overflow-x: hidden;
transition: all 0.5s ease 0s;
}
.overlay--active {
width: 100%;
}
.overlay__content {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
.overlay a {
padding: 15px;
font-size: 36px;
display: block;
transition: all 0.3s ease 0s;
}
.overlay a:hover,
.overlay a:focus {
color: #0088a9;
}
.overlay .close {
position: absolute;
top: 20px;
right: 45px;
font-size: 60px;
color: #edf0f1;
cursor: pointer;
}
@media screen and (max-height: 450px) {
.overlay a {
font-size: 20px;
}
.overlay .close {
font-size: 40px;
top: 15px;
right: 35px;
}
}
@media only screen and (max-width: 800px) {
.nav__links,
.cta {
display: none;
}
.menu {
display: initial;
}
}

View File

@ -0,0 +1,13 @@
import React from 'react'
import './Css/Footer.css'
function Footer() {
return (
<footer>
<p className="copyright">Made by <a href="https://github.com/vallezw" target="_blank">Valentin Zwerschke</a> | <a href="https://github.com/vallezw/Image-Uploader" target="_blank" >vallezw/Image-Uploader</a></p>
</footer>
)
}
export default Footer

View File

@ -0,0 +1,25 @@
import React from 'react'
import '../Css/Navbar.css'
import logo from '../../../Images/logo.png'
import { Lock as LockIcon } from '@material-ui/icons';
function Navbar() {
return (
<header>
<div className="logo"><a className="logo" href="/"><img src={logo} className="imageNav" alt="Logo"/><p className="logo">Upload your Project Images</p></a></div>
<nav>
<ul className="nav__links">
<li><a href="/groups">Groups</a></li>
<li><a href="/slideshow">Slideshow</a></li>
<li><a href="/moderation"><LockIcon style={{ fontSize: 18, verticalAlign: 'text-bottom', marginRight: 6 }} aria-hidden="true" />Moderation</a></li>
<li><a href="https://www.hobbyhimmel.de/ueber-uns/konzept/">About</a></li>
</ul>
</nav>
<a className="cta" href="/">Upload</a>
</header>
)
}
export default Navbar

View File

@ -0,0 +1,73 @@
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import Grow from '@material-ui/core/Grow';
// Components
import StyledDropzone from './StyledDropzone'
import UploadButton from './UploadButton'
import Loading from './LoadingAnimation/Loading';
import '../../App.css'
const useStyles = makeStyles({
root: {
paddingLeft: "40px",
paddingRight: "40px",
paddingTop: "10px",
paddingBottom: "10px",
borderRadius: "7px",
boxShadow: "0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19)",
display: "grid",
placeItems: "center"
},
headerText: {
fontFamily: "roboto",
fontWeight: "300",
fontSize: 20,
textAlign: "center",
paddingBottom: 0,
lineHeight: "0em"
},
subheaderText: {
fontFamily: "roboto",
fontWeight: "300",
fontSize: 11,
color: "grey",
textAlign: "center",
lineHeight: "0.7em",
paddingBottom: "20px"
}
});
export default function ImageUploadCard(props) {
const classes = useStyles();
const checked = true
return (
<div>
{!props.loading?
<div className="cardContainer">
<Grow in={checked}>
<Card className={classes.root}>
<CardContent>
<p className={classes.headerText}>Upload your image</p>
<p className={classes.subheaderText}>File should be Jpeg, Png, ...</p>
<StyledDropzone handleLoading={props.handleLoading} handleResponse={props.handleResponse} />
<UploadButton handleLoading={props.handleLoading} handleResponse={props.shandleResponse} />
</CardContent>
</Card>
</Grow>
</div>
:
<div className="loadingContainer">
<Loading />
</div>
}
</div>
);
}

View File

@ -0,0 +1,26 @@
import '../../../App.css'
import Lottie from 'react-lottie';
import animationData from './animation.json';
export default function Loading() {
const defaultOptions = {
loop: true,
autoplay: true,
animationData: animationData,
rendererSettings: {
preserveAspectRatio: "xMidYMid slice"
},
};
return (
<div className="loading">
<Lottie
options={defaultOptions}
height={400}
width={400}
isClickToPauseDisabled={true}
/>
</div>
)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,164 @@
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { TextField, Typography, Grid, Box } from '@material-ui/core';
const useStyles = makeStyles({
container: {
marginTop: '20px',
marginBottom: '20px'
},
sectionTitle: {
fontFamily: 'roboto',
fontSize: '18px',
color: '#333333',
marginBottom: '15px',
display: 'block',
fontWeight: '500'
},
fieldLabel: {
fontFamily: 'roboto',
fontSize: '14px',
color: '#555555',
marginBottom: '8px',
display: 'block'
},
textField: {
width: '100%',
marginBottom: '15px',
'& .MuiOutlinedInput-root': {
borderRadius: '8px'
}
},
requiredField: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
'& fieldset': {
borderColor: '#E57373'
}
}
},
optionalField: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
'& fieldset': {
borderColor: '#E0E0E0'
}
}
},
characterCount: {
fontSize: '12px',
color: '#999999',
textAlign: 'right',
marginTop: '-10px',
marginBottom: '10px'
},
requiredIndicator: {
color: '#E57373',
fontSize: '16px'
},
optionalIndicator: {
color: '#9E9E9E',
fontSize: '12px',
fontStyle: 'italic'
}
});
function DescriptionInput({
metadata = {},
onMetadataChange
}) {
const classes = useStyles();
const handleFieldChange = (field, value) => {
const updatedMetadata = {
...metadata,
[field]: value
};
onMetadataChange(updatedMetadata);
};
const currentYear = new Date().getFullYear();
return (
<div className={classes.container}>
<Typography className={classes.sectionTitle}>
📝 Projekt-Informationen
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography className={classes.fieldLabel}>
Jahr <span className={classes.requiredIndicator}>*</span>
</Typography>
<TextField
className={`${classes.textField} ${classes.requiredField}`}
variant="outlined"
type="number"
value={metadata.year || currentYear}
onChange={(e) => handleFieldChange('year', parseInt(e.target.value))}
placeholder={currentYear.toString()}
inputProps={{
min: 1900,
max: currentYear + 10
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography className={classes.fieldLabel}>
Titel <span className={classes.requiredIndicator}>*</span>
</Typography>
<TextField
className={`${classes.textField} ${classes.requiredField}`}
variant="outlined"
value={metadata.title || ''}
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="z.B. Wohnzimmer Renovierung"
inputProps={{
maxLength: 100
}}
/>
</Grid>
<Grid item xs={12}>
<Typography className={classes.fieldLabel}>
Beschreibung <span className={classes.optionalIndicator}>(optional)</span>
</Typography>
<TextField
className={`${classes.textField} ${classes.optionalField}`}
multiline
rows={3}
variant="outlined"
value={metadata.description || ''}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder="Detaillierte Beschreibung des Projekts..."
inputProps={{
maxLength: 500
}}
/>
<div className={classes.characterCount}>
{(metadata.description || '').length} / 500 Zeichen
</div>
</Grid>
<Grid item xs={12}>
<Typography className={classes.fieldLabel}>
Name/Ersteller <span className={classes.optionalIndicator}>(optional)</span>
</Typography>
<TextField
className={`${classes.textField} ${classes.optionalField}`}
variant="outlined"
value={metadata.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder="Dein Name oder Projektersteller"
inputProps={{
maxLength: 50
}}
/>
</Grid>
</Grid>
</div>
);
}
export default DescriptionInput;

View File

@ -0,0 +1,142 @@
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Grid, Card, CardMedia, IconButton, Typography, Box } from '@material-ui/core';
import { Close as CloseIcon, DragIndicator as DragIcon } from '@material-ui/icons';
const useStyles = makeStyles({
galleryContainer: {
marginTop: '20px',
marginBottom: '20px'
},
imageCard: {
position: 'relative',
borderRadius: '8px',
overflow: 'hidden',
transition: 'transform 0.2s ease',
'&:hover': {
transform: 'scale(1.02)'
}
},
imageMedia: {
height: 150,
objectFit: 'cover'
},
removeButton: {
position: 'absolute',
top: '5px',
right: '5px',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
color: '#f44336',
'&:hover': {
backgroundColor: '#ffffff',
color: '#d32f2f'
}
},
dragHandle: {
position: 'absolute',
top: '5px',
left: '5px',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
color: '#666666',
cursor: 'grab'
},
imageOrder: {
position: 'absolute',
bottom: '5px',
left: '5px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
borderRadius: '12px',
padding: '2px 8px',
fontSize: '12px',
fontWeight: 'bold'
},
fileName: {
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: '5px',
fontSize: '11px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
},
galleryHeader: {
marginBottom: '15px',
fontFamily: 'roboto',
color: '#333333'
}
});
function ImagePreviewGallery({ images, onRemoveImage, onReorderImages }) {
const classes = useStyles();
if (!images || images.length === 0) {
return null;
}
const handleRemoveImage = (index) => {
onRemoveImage(index);
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className={classes.galleryContainer}>
<Typography variant="h6" className={classes.galleryHeader}>
Vorschau ({images.length} Bild{images.length !== 1 ? 'er' : ''})
</Typography>
<Grid container spacing={2}>
{images.map((image, index) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<Card className={classes.imageCard}>
<CardMedia
component="img"
className={classes.imageMedia}
image={URL.createObjectURL(image)}
alt={`Vorschau ${index + 1}`}
/>
<IconButton
className={classes.removeButton}
size="small"
onClick={() => handleRemoveImage(index)}
title="Bild entfernen"
>
<CloseIcon fontSize="small" />
</IconButton>
<IconButton
className={classes.dragHandle}
size="small"
title="Zum Sortieren ziehen"
>
<DragIcon fontSize="small" />
</IconButton>
<div className={classes.imageOrder}>
{index + 1}
</div>
<div className={classes.fileName} title={`${image.name} (${formatFileSize(image.size)})`}>
{image.name} {formatFileSize(image.size)}
</div>
</Card>
</Grid>
))}
</Grid>
</div>
);
}
export default ImagePreviewGallery;

View File

@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
dropzone: {
border: '2px dashed #cccccc',
borderRadius: '8px',
padding: '40px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
backgroundColor: '#fafafa',
minHeight: '200px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
'&:hover': {
borderColor: '#999999',
backgroundColor: '#f0f0f0'
}
},
dropzoneActive: {
borderColor: '#4CAF50',
backgroundColor: '#e8f5e8'
},
dropzoneText: {
fontSize: '18px',
fontFamily: 'roboto',
color: '#666666',
margin: '10px 0'
},
dropzoneSubtext: {
fontSize: '14px',
color: '#999999',
fontFamily: 'roboto'
},
fileCount: {
fontSize: '16px',
color: '#4CAF50',
fontWeight: 'bold',
marginTop: '10px'
}
});
function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
const classes = useStyles();
const onDrop = useCallback((acceptedFiles) => {
// Filter nur Bilddateien
const imageFiles = acceptedFiles.filter(file =>
file.type.startsWith('image/')
);
if (imageFiles.length !== acceptedFiles.length) {
alert('Nur Bilddateien sind erlaubt!');
}
onImagesSelected(imageFiles);
}, [onImagesSelected]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.bmp', '.webp']
},
multiple: true,
maxSize: 10 * 1024 * 1024 // 10MB pro Datei
});
return (
<div
{...getRootProps()}
className={`${classes.dropzone} ${isDragActive ? classes.dropzoneActive : ''}`}
>
<input {...getInputProps()} />
<div className={classes.dropzoneText}>
{isDragActive ?
'Bilder hierher ziehen...' :
'Mehrere Bilder hier hinziehen oder klicken zum Auswählen'
}
</div>
<div className={classes.dropzoneSubtext}>
Unterstützte Formate: JPG, PNG, GIF, WebP (max. 10MB pro Datei)
</div>
{selectedImages.length > 0 && (
<div className={classes.fileCount}>
📸 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt
</div>
)}
</div>
);
}
export default MultiImageDropzone;

View File

@ -0,0 +1,142 @@
import React, { useCallback } from 'react';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
dropzone: {
border: '2px dashed #cccccc',
borderRadius: '8px',
padding: '40px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
backgroundColor: '#fafafa',
minHeight: '200px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
'&:hover': {
borderColor: '#999999',
backgroundColor: '#f0f0f0'
}
},
dropzoneActive: {
borderColor: '#4CAF50',
backgroundColor: '#e8f5e8'
},
dropzoneText: {
fontSize: '18px',
fontFamily: 'roboto',
color: '#666666',
margin: '10px 0'
},
dropzoneSubtext: {
fontSize: '14px',
color: '#999999',
fontFamily: 'roboto'
},
fileCount: {
fontSize: '16px',
color: '#4CAF50',
fontWeight: 'bold',
marginTop: '10px'
},
hiddenInput: {
display: 'none'
}
});
function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
const classes = useStyles();
const handleFiles = (files) => {
// Filter nur Bilddateien
const imageFiles = Array.from(files).filter(file =>
file.type.startsWith('image/')
);
if (imageFiles.length !== files.length) {
alert('Nur Bilddateien sind erlaubt!');
}
if (imageFiles.length > 0) {
console.log('Selected images:', imageFiles);
onImagesSelected(imageFiles);
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
handleFiles(files);
};
const handleFileInputChange = (e) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
const handleClick = () => {
const fileInput = document.getElementById('multi-file-input');
if (fileInput) {
fileInput.click();
}
};
return (
<div>
<div
className={classes.dropzone}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<div className={classes.dropzoneText}>
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
</div>
<div className={classes.dropzoneSubtext}>
Unterstützte Formate: JPG, PNG, GIF, WebP (max. 10MB pro Datei)
</div>
{selectedImages.length > 0 && (
<div className={classes.fileCount}>
{selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt
</div>
)}
</div>
<input
id="multi-file-input"
type="file"
multiple
accept="image/*"
onChange={handleFileInputChange}
className={classes.hiddenInput}
/>
</div>
);
}
export default MultiImageDropzone;

View File

@ -0,0 +1,79 @@
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { LinearProgress, Typography, Box } from '@material-ui/core';
const useStyles = makeStyles({
container: {
marginTop: '20px',
marginBottom: '20px',
padding: '20px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
backgroundColor: '#fafafa'
},
progressBar: {
height: '8px',
borderRadius: '4px',
marginBottom: '10px'
},
progressText: {
fontSize: '14px',
color: '#666666',
textAlign: 'center'
},
fileInfo: {
fontSize: '12px',
color: '#999999',
textAlign: 'center',
marginTop: '5px'
}
});
function UploadProgress({
progress = 0,
currentFile = null,
totalFiles = 0,
completedFiles = 0,
isUploading = false
}) {
const classes = useStyles();
if (!isUploading) {
return null;
}
return (
<div className={classes.container}>
<Box display="flex" alignItems="center" marginBottom={2}>
<Box width="100%" marginRight={1}>
<LinearProgress
variant="determinate"
value={progress}
className={classes.progressBar}
/>
</Box>
<Box minWidth={35}>
<Typography variant="body2" color="textSecondary">
{Math.round(progress)}%
</Typography>
</Box>
</Box>
<div className={classes.progressText}>
{currentFile ? (
<>📤 Uploading: {currentFile}</>
) : (
<>📤 Uploading {totalFiles} Bild{totalFiles !== 1 ? 'er' : ''}...</>
)}
</div>
{totalFiles > 1 && (
<div className={classes.fileInfo}>
{completedFiles} von {totalFiles} Dateien abgeschlossen
</div>
)}
</div>
);
}
export default UploadProgress;

View File

@ -0,0 +1,143 @@
i {
opacity: 0;
font-size: 28px;
color: #1F1E1E;
will-change: transform;
-webkit-transform: scale(.1);
transform: scale(.1);
-webkit-transition: all .3s ease;
transition: all .3s ease;
}
.btn_wrap {
margin-top: 50px;
display: block;
margin-left: auto;
margin-right: auto;
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
overflow: hidden;
cursor: pointer;
width: 232px;
height: 60px;
background-color: #EEEEED;
border-radius: 80px;
padding: 0 18px;
will-change: transform;
-webkit-transition: all .2s ease-in-out;
transition: all .2s ease-in-out;
}
.btn_wrap:hover {
/* transition-delay: .4s; */
-webkit-transform: scale(1.1);
transform: scale(1.1)
}
.socialSpan {
position: absolute;
z-index: 99;
width: 240px;
height: 72px;
border-radius: 80px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 20px;
text-align: center;
line-height: 70px;
letter-spacing: 2px;
color: #EEEEED;
background-color: #25252A;
padding: 0 18px;
-webkit-transition: all 1.2s ease;
transition: all 1.2s ease;
}
.shareWrap {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-pack: distribute;
justify-content: space-around;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
width: 240px;
height: 64px;
border-radius: 80px;
}
.shareWrap i:nth-of-type(1) {
-webkit-transition-delay: .5s;
transition-delay: .5s;
}
.shareWrap i:nth-of-type(2) {
-webkit-transition-delay: .9s;
transition-delay: .9s;
}
.shareWrap i:nth-of-type(3) {
-webkit-transition-delay: .7s;
transition-delay: .7s;
}
.shareWrap i:nth-of-type(4) {
-webkit-transition-delay: .4s;
transition-delay: .4s;
}
.btn_wrap:hover span {
-webkit-transition-delay: .25s;
transition-delay: .25s;
-webkit-transform: translateX(-520px);
transform: translateX(-520px)
}
.btn_wrap:hover i {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
.iconButton {
cursor: pointer;
border: none;
transition: all .2s ease-in-out;
}
.iconButton:focus {
outline: none;
}
.iconButton:hover {
transform: scale(1.2)
}
.iconButton:after {
content: "";
background: #f1f1f1;
display: block;
position: absolute;
padding-top: 300%;
padding-left: 350%;
margin-left: -0px !important;
margin-top: -120%;
opacity: 0;
transition: all 0.8s
}
.iconButton:active:after {
padding: 0;
margin: 0;
opacity: 1;
transition: 0s
}

View File

@ -0,0 +1,24 @@
import React, { Component } from 'react'
import './Css/SocialMedia.css'
export default class SocialMediaShareButtons extends Component {
render() {
const path = this.props.image_url
const URL = `${window._env_.CLIENT_URL}/upload/${path}`
const SERVER_URL = `${window._env_.API_URL}/download/${path}`
const TEXT = `Hey, look at this cool image I uploaded!`
return (
<div className="btn_wrap">
<span className="socialSpan">Share</span>
<div className="shareWrap">
<a href={`https://www.facebook.com/sharer/sharer.php?u=${URL}`} rel="noopener noreferrer" target="_blank" className="iconButton"><i className="fab fa-facebook-f"></i></a>
<a href={`https://twitter.com/intent/tweet?url=${URL}&text=${TEXT}`} target="_blank" rel="noopener noreferrer" className="iconButton"><i className="fab fa-twitter"></i></a>
<a href={`whatsapp://send?text=${TEXT}%0a${URL}`} data-action="share/whatsapp/share" className="iconButton"><i className="fab fa-whatsapp"></i></a>
<button onClick={() => {navigator.clipboard.writeText(URL)}} className="iconButton" ><i className="fas fa-copy"></i></button>
<a download="UploadedImage" href={SERVER_URL} className="iconButton"><i className="fas fa-download"></i></a>
</div>
</div>
)
}
}

View File

@ -0,0 +1,96 @@
import React, { useMemo, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { sendRequest } from '../../Utils/sendRequest'
import goingUpImage from '../../Images/going_up.svg'
const baseStyle = {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
borderWidth: 2,
borderRadius: 2,
borderColor: '#eeeeee',
borderStyle: 'dashed',
backgroundColor: '#fafafa',
color: '#bdbdbd',
outline: 'none',
transition: 'border .24s ease-in-out',
};
const activeStyle = {
borderColor: '#2196f3'
};
const acceptStyle = {
borderColor: '#00e676'
};
const rejectStyle = {
borderColor: '#ff1744'
};
const textStyle = {
fontFamily: "Roboto",
fontWeight: "300",
fontSize: "14px"
}
const divStyle = {
float:"left",
position:"absolute",
marginTop: "56px",
marginRight: "10px",
padding:"20px",
color:"#FFFFFF",
cursor: "pointer"
}
export default function StyledDropzone(props) {
const {
getRootProps,
isDragActive,
isDragAccept,
isDragReject
} = useDropzone({accept: 'image/jpeg, image/png, image/gif', onDrop: (file) => {
sendRequest(file[0], props.handleLoading, props.handleResponse)
}});
const style = useMemo(() => ({
...baseStyle,
...(isDragActive ? activeStyle : {}),
...(isDragAccept ? acceptStyle : {}),
...(isDragReject ? rejectStyle : {})
}), [
isDragActive,
isDragReject,
isDragAccept
]);
const inputFile = useRef(null)
const handleChange = event => {
const fileUploaded = event.target.files[0];
sendRequest(fileUploaded, props.handleLoading, props.handleResponse)
}
const onDivClick = () => {
inputFile.current.click();
}
return (
<div className="container">
<div {...getRootProps({style})}>
<p style={textStyle}>Drag 'n' drop your image here</p>
<div style={divStyle} onClick={onDivClick}>
<input type='file' id='file' ref={inputFile} style={{display: 'none'}} onChange={handleChange}/>
</div>
<img src={goingUpImage} alt="goingUpImage" style={{width: "150px"}} />
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
import React, { Fragment, useRef } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { sendRequest } from '../../Utils/sendRequest';
import Button from '@material-ui/core/Button';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
const useStyles = makeStyles({
button: {
margin: 10,
marginTop: 20,
left: "14%"
},
});
export default function UploadButton(props) {
const classes = useStyles();
const inputFile = useRef(null)
const onButtonClick = () => {
inputFile.current.click();
}
const handleChange = event => {
const fileUploaded = event.target.files[0];
sendRequest(fileUploaded, props.handleLoading, props.handleResponse)
}
return(
<Fragment>
<input type='file' id='file' ref={inputFile} style={{display: 'none'}} onChange={handleChange}/>
<Button
variant="outlined"
color="primary"
size="small"
className={classes.button}
startIcon={<CloudUploadIcon />}
onClick={onButtonClick}
>
Choose Image
</Button>
</Fragment>
)
}

View File

@ -0,0 +1,43 @@
import React, { Component, Fragment } from 'react'
import './Css/Image.css'
import './Css/Image.scss'
export default class UploadedImage extends Component {
state = {
showModal: false,
caption: '',
modalSrc: '',
};
render() {
const image_url = window._env_.API_URL + "/upload/" + this.props.image_url
return (
<Fragment>
<img
id="myImg"
src={image_url}
onClick={() => {
this.setState({ showModal: true, caption: "Uploaded", modalSrc: image_url});
}}
alt="Uploaded"
onError={() => this.props.imageNotFound()}
/>
<div
id="myModal"
className="modal"
style={{ display: this.state.showModal ? 'block' : 'none' }}
>
<div>
<span className="close" onClick={() => this.setState({ showModal: false })}>
&times;
</span>
<img className="modal-content" id="img01" src={this.state.modalSrc} alt="Uploaded"/>
</div>
</div>
</Fragment>
);
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,68 @@
.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,360 @@
.moderation-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.moderation-page h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.moderation-loading, .moderation-error {
text-align: center;
padding: 50px;
font-size: 18px;
}
.moderation-error {
color: #dc3545;
}
/* Statistiken */
.moderation-stats {
display: flex;
justify-content: center;
gap: 40px;
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
}
.stat-item {
text-align: center;
}
.stat-number {
display: block;
font-size: 2.5rem;
font-weight: bold;
color: #007bff;
}
.stat-label {
display: block;
font-size: 0.9rem;
color: #6c757d;
margin-top: 5px;
}
/* Sections */
.moderation-section {
margin-bottom: 50px;
}
.moderation-section h2 {
color: #333;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
margin-bottom: 25px;
}
.no-groups {
text-align: center;
color: #6c757d;
font-style: italic;
padding: 30px;
}
/* Groups Grid */
.groups-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
/* Group Card */
.group-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.group-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.group-card.pending {
border-left: 5px solid #ffc107;
}
.group-card.approved {
border-left: 5px solid #28a745;
}
.group-preview {
position: relative;
height: 200px;
background: #f8f9fa;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-preview {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6c757d;
font-style: italic;
}
.image-count {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
}
.group-info {
padding: 15px;
}
.group-info h3 {
margin: 0 0 10px 0;
color: #333;
}
.group-meta {
color: #007bff;
font-weight: 500;
margin: 5px 0;
}
.group-description {
color: #6c757d;
font-size: 0.9rem;
margin: 8px 0;
line-height: 1.4;
}
.upload-date {
color: #6c757d;
font-size: 0.8rem;
margin: 10px 0 0 0;
}
.group-actions {
padding: 15px;
background: #f8f9fa;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 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-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.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;
}
/* Image Modal */
.image-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.image-modal {
background: white;
border-radius: 12px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.modal-header h2 {
margin: 0;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6c757d;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.group-details {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.group-details p {
margin: 5px 0;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.image-item {
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.modal-image {
width: 100%;
height: 150px;
object-fit: cover;
display: block;
}
.image-actions {
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
}
.image-name {
font-size: 0.8rem;
color: #6c757d;
flex: 1;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Responsive */
@media (max-width: 768px) {
.moderation-stats {
flex-direction: column;
gap: 20px;
}
.groups-grid {
grid-template-columns: 1fr;
}
.group-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
.image-modal {
max-width: 95vw;
max-height: 95vh;
}
.images-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
}

View File

@ -0,0 +1,19 @@
.rowContainer {
display: flex;
flex-direction: row;
}
.rootUploadWrap {
display: flex;
flex-direction: column;
justify-content: center;
}
.FZFImage {
display: block;
margin-left: auto;
margin-right: auto;
width: 50%;
height: 50%;
padding-top: 50px;
}

View File

@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { makeStyles } from '@material-ui/core/styles';
import {
Container,
Card,
CardContent,
Typography,
Button,
Grid,
CardMedia,
Box,
CircularProgress,
Chip
} from '@material-ui/core';
import {
Slideshow as SlideshowIcon,
Add as AddIcon,
Home as HomeIcon
} from '@material-ui/icons';
import Swal from 'sweetalert2/dist/sweetalert2.js';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
// Utils
import { fetchAllGroups, deleteGroup } from '../../Utils/batchUpload';
// Styles
import '../../App.css';
import '../ComponentUtils/Css/Background.css';
const useStyles = makeStyles({
container: {
paddingTop: '20px',
paddingBottom: '40px',
minHeight: '80vh'
},
headerCard: {
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
marginBottom: '30px',
textAlign: 'center',
padding: '20px'
},
headerTitle: {
fontFamily: 'roboto',
fontWeight: '500',
fontSize: '28px',
color: '#333333',
marginBottom: '10px'
},
headerSubtitle: {
fontFamily: 'roboto',
fontSize: '16px',
color: '#666666',
marginBottom: '20px'
},
groupCard: {
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease',
height: '100%',
display: 'flex',
flexDirection: 'column',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.15)'
}
},
groupImage: {
height: '180px',
objectFit: 'cover'
},
groupContent: {
flexGrow: 1,
display: 'flex',
flexDirection: 'column'
},
groupTitle: {
fontFamily: 'roboto',
fontWeight: '500',
fontSize: '16px',
color: '#333333',
marginBottom: '8px',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
},
groupMeta: {
fontSize: '12px',
color: '#999999',
marginBottom: '15px'
},
groupActions: {
marginTop: 'auto',
display: 'flex',
gap: '8px',
justifyContent: 'space-between'
},
viewButton: {
borderRadius: '20px',
textTransform: 'none',
fontSize: '12px',
padding: '6px 16px',
background: 'linear-gradient(45deg, #4CAF50 30%, #45a049 90%)',
color: 'white',
'&:hover': {
background: 'linear-gradient(45deg, #45a049 30%, #4CAF50 90%)'
}
},
actionButtons: {
display: 'flex',
gap: '15px',
justifyContent: 'center',
flexWrap: 'wrap',
marginTop: '20px'
},
primaryButton: {
borderRadius: '25px',
padding: '12px 30px',
fontSize: '16px',
fontWeight: '500',
textTransform: 'none',
background: 'linear-gradient(45deg, #2196F3 30%, #1976D2 90%)',
color: 'white',
'&:hover': {
background: 'linear-gradient(45deg, #1976D2 30%, #2196F3 90%)',
transform: 'translateY(-2px)'
}
},
homeButton: {
borderRadius: '25px',
padding: '12px 30px',
fontSize: '16px',
fontWeight: '500',
textTransform: 'none',
border: '2px solid #4CAF50',
color: '#4CAF50',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#4CAF50',
color: 'white',
transform: 'translateY(-2px)'
}
},
emptyState: {
textAlign: 'center',
padding: '60px 20px'
},
loadingContainer: {
textAlign: 'center',
padding: '60px 20px'
}
});
function GroupsOverviewPage() {
const classes = useStyles();
const history = useHistory();
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoading(true);
const response = await fetchAllGroups();
setGroups(response.groups || []);
setError(null);
} catch (err) {
setError(err.message);
console.error('Error loading groups:', err);
} finally {
setLoading(false);
}
};
const handleViewSlideshow = (groupId) => {
history.push(`/slideshow/${groupId}`);
};
const handleCreateNew = () => {
history.push('/multi-upload');
};
const handleGoHome = () => {
history.push('/');
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
if (loading) {
return (
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" className={classes.container}>
<div className={classes.loadingContainer}>
<CircularProgress size={60} color="primary" />
<Typography variant="h6" style={{ marginTop: '20px', color: '#666666' }}>
Slideshows werden geladen...
</Typography>
</div>
</Container>
<Footer />
</div>
);
}
return (
<div className="allContainer">
<Helmet>
<title>Gruppenübersicht - Interne Verwaltung</title>
<meta name="robots" content="noindex, nofollow, nosnippet, noarchive" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="description" content="Interne Gruppenübersicht - Nicht öffentlich zugänglich" />
</Helmet>
<Navbar />
<Container maxWidth="lg" className={classes.container}>
{/* Header */}
<Card className={classes.headerCard}>
<Typography className={classes.headerTitle}>
🎬 Alle Slideshows
</Typography>
<Typography className={classes.headerSubtitle}>
Verwalten Sie Ihre hochgeladenen Bildersammlungen
</Typography>
<div className={classes.actionButtons}>
<Button
className={classes.primaryButton}
onClick={handleCreateNew}
startIcon={<AddIcon />}
size="large"
>
Neue Slideshow erstellen
</Button>
<Button
className={classes.homeButton}
onClick={handleGoHome}
startIcon={<HomeIcon />}
size="large"
>
🏠 Zur Startseite
</Button>
</div>
</Card>
{/* Groups Grid */}
{error ? (
<div className={classes.emptyState}>
<Typography variant="h6" style={{ color: '#f44336', marginBottom: '20px' }}>
😕 Fehler beim Laden
</Typography>
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
{error}
</Typography>
<Button onClick={loadGroups} className={classes.primaryButton}>
🔄 Erneut versuchen
</Button>
</div>
) : groups.length === 0 ? (
<div className={classes.emptyState}>
<Typography variant="h4" style={{ color: '#666666', marginBottom: '20px' }}>
📸 Keine Slideshows vorhanden
</Typography>
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
</Typography>
<Button
className={classes.primaryButton}
onClick={handleCreateNew}
size="large"
>
Erste Slideshow erstellen
</Button>
</div>
) : (
<>
<Box marginBottom={2}>
<Typography variant="h6" style={{ color: '#666666' }}>
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
</Typography>
</Box>
<Grid container spacing={3}>
{groups.map((group) => (
<Grid item xs={12} sm={6} md={4} key={group.groupId}>
<Card className={classes.groupCard}>
{group.images && group.images.length > 0 && (
<CardMedia
component="img"
className={classes.groupImage}
image={group.images[0].filePath}
alt={group.description || 'Slideshow Vorschau'}
/>
)}
<CardContent className={classes.groupContent}>
<Typography className={classes.groupTitle}>
{group.description || 'Unbenannte Slideshow'}
</Typography>
<Typography className={classes.groupMeta}>
📅 {formatDate(group.uploadDate)} 📸 {group.images?.length || 0} Bilder
</Typography>
<div className={classes.groupActions}>
<Button
className={classes.viewButton}
onClick={() => handleViewSlideshow(group.groupId)}
startIcon={<SlideshowIcon />}
fullWidth
>
Anzeigen
</Button>
</div>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</>
)}
</Container>
<div className="footerContainer">
<Footer />
</div>
</div>
);
}
export default GroupsOverviewPage;

View File

@ -0,0 +1,346 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import './Css/ModerationPage.css';
const ModerationPage = () => {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedGroup, setSelectedGroup] = useState(null);
const [showImages, setShowImages] = useState(false);
useEffect(() => {
loadModerationGroups();
}, []);
const loadModerationGroups = async () => {
try {
setLoading(true);
const response = await fetch('/moderation/groups');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setGroups(data.groups);
} catch (error) {
console.error('Fehler beim Laden der Moderations-Gruppen:', error);
setError('Fehler beim Laden der Gruppen');
} finally {
setLoading(false);
}
};
const approveGroup = async (groupId, approved) => {
try {
const response = await fetch(`/groups/${groupId}/approve`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ approved: approved })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update local state
setGroups(groups.map(group =>
group.group_id === groupId
? { ...group, approved: approved }
: group
));
} catch (error) {
console.error('Fehler beim Freigeben der Gruppe:', error);
alert('Fehler beim Freigeben der Gruppe');
}
};
const deleteImage = async (groupId, imageId) => {
console.log('deleteImage called with:', { groupId, imageId });
console.log('API_URL:', window._env_.API_URL);
try {
// Use relative URL to go through Nginx proxy
const url = `/groups/${groupId}/images/${imageId}`;
console.log('DELETE request to:', url);
const response = await fetch(url, {
method: 'DELETE'
});
console.log('Response status:', response.status);
console.log('Response ok:', response.ok);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Remove image from selectedGroup
if (selectedGroup && selectedGroup.group_id === groupId) {
const updatedImages = selectedGroup.images.filter(img => img.id !== imageId);
setSelectedGroup({
...selectedGroup,
images: updatedImages,
imageCount: updatedImages.length
});
}
// Update group image count
setGroups(groups.map(group =>
group.group_id === groupId
? { ...group, image_count: group.image_count - 1 }
: group
));
} catch (error) {
console.error('Fehler beim Löschen des Bildes:', error);
console.error('Error details:', error.message, error.stack);
alert('Fehler beim Löschen des Bildes: ' + error.message);
}
};
const deleteGroup = async (groupId) => {
if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
return;
}
try {
const response = await fetch(`/groups/${groupId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
setGroups(groups.filter(group => group.group_id !== groupId));
if (selectedGroup && selectedGroup.group_id === groupId) {
setSelectedGroup(null);
setShowImages(false);
}
} catch (error) {
console.error('Fehler beim Löschen der Gruppe:', error);
alert('Fehler beim Löschen der Gruppe');
}
};
const viewGroupImages = async (group) => {
try {
const response = await fetch(`/moderation/groups/${group.group_id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setSelectedGroup(data);
setShowImages(true);
} catch (error) {
console.error('Fehler beim Laden der Bilder:', error);
alert('Fehler beim Laden der Bilder');
}
};
if (loading) {
return <div className="moderation-loading">Lade Gruppen...</div>;
}
if (error) {
return <div className="moderation-error">{error}</div>;
}
const pendingGroups = groups.filter(g => !g.approved);
const approvedGroups = groups.filter(g => g.approved);
return (
<div className="moderation-page">
<Helmet>
<title>Moderation - Interne Verwaltung</title>
<meta name="robots" content="noindex, nofollow, nosnippet, noarchive" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
</Helmet>
<h1>Moderation</h1>
<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>
{/* Wartende Gruppen */}
<section className="moderation-section">
<h2>🔍 Wartende Freigabe ({pendingGroups.length})</h2>
{pendingGroups.length === 0 ? (
<p className="no-groups">Keine wartenden Gruppen</p>
) : (
<div className="groups-grid">
{pendingGroups.map(group => (
<GroupCard
key={group.group_id}
group={group}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={true}
/>
))}
</div>
)}
</section>
{/* Freigegebene Gruppen */}
<section className="moderation-section">
<h2> Freigegebene Gruppen ({approvedGroups.length})</h2>
{approvedGroups.length === 0 ? (
<p className="no-groups">Keine freigegebenen Gruppen</p>
) : (
<div className="groups-grid">
{approvedGroups.map(group => (
<GroupCard
key={group.group_id}
group={group}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={false}
/>
))}
</div>
)}
</section>
{/* Bilder-Modal */}
{showImages && selectedGroup && (
<ImageModal
group={selectedGroup}
onClose={() => {
setShowImages(false);
setSelectedGroup(null);
}}
onDeleteImage={deleteImage}
/>
)}
</div>
);
};
const GroupCard = ({ group, onApprove, onViewImages, onDelete, isPending }) => {
const previewUrl = group.preview_image ? `/download/${group.preview_image.split('/').pop()}` : null;
return (
<div className={`group-card ${isPending ? 'pending' : 'approved'}`}>
<div className="group-preview">
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="preview-image" />
) : (
<div className="no-preview">Kein Vorschaubild</div>
)}
<div className="image-count">{group.image_count} Bilder</div>
</div>
<div className="group-info">
<h3>{group.title}</h3>
<p className="group-meta">{group.year} {group.name}</p>
{group.description && (
<p className="group-description">{group.description}</p>
)}
<p className="upload-date">
Hochgeladen: {new Date(group.upload_date).toLocaleDateString('de-DE')}
</p>
</div>
<div className="group-actions">
<button
className="btn btn-secondary"
onClick={() => onViewImages(group)}
>
👁 Bilder ansehen
</button>
{isPending ? (
<button
className="btn btn-success"
onClick={() => onApprove(group.group_id, true)}
>
Freigeben
</button>
) : (
<button
className="btn btn-warning"
onClick={() => onApprove(group.group_id, false)}
>
Sperren
</button>
)}
<button
className="btn btn-danger"
onClick={() => onDelete(group.group_id)}
>
🗑 Löschen
</button>
</div>
</div>
);
};
const ImageModal = ({ group, onClose, onDeleteImage }) => {
return (
<div className="image-modal-overlay" onClick={onClose}>
<div className="image-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{group.title}</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="group-details">
<p><strong>Jahr:</strong> {group.year}</p>
<p><strong>Ersteller:</strong> {group.name}</p>
{group.description && (
<p><strong>Beschreibung:</strong> {group.description}</p>
)}
<p><strong>Bilder:</strong> {group.images.length}</p>
</div>
<div className="images-grid">
{group.images.map(image => (
<div key={image.id} className="image-item">
<img
src={`/download/${image.fileName}`}
alt={image.originalName}
className="modal-image"
/>
<div className="image-actions">
<span className="image-name">{image.originalName}</span>
<button
className="btn btn-danger btn-sm"
onClick={() => onDeleteImage(group.group_id, image.id)}
title="Bild löschen"
>
🗑
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default ModerationPage;

View File

@ -0,0 +1,284 @@
import React, { useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Button, Card, CardContent, Typography, Container, Box } from '@material-ui/core';
import Swal from 'sweetalert2/dist/sweetalert2.js';
import 'sweetalert2/src/sweetalert2.scss';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
import MultiImageDropzone from '../ComponentUtils/MultiUpload/SimpleMultiImageDropzone';
import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery';
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress';
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
// Utils
import { uploadImageBatch } from '../../Utils/batchUpload';
// Styles
import '../../App.css';
import '../ComponentUtils/Css/Background.css';
const useStyles = makeStyles({
container: {
paddingTop: '20px',
paddingBottom: '40px',
minHeight: '80vh'
},
card: {
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
padding: '20px',
marginBottom: '20px'
},
headerText: {
fontFamily: 'roboto',
fontWeight: '400',
fontSize: '28px',
textAlign: 'center',
marginBottom: '10px',
color: '#333333'
},
subheaderText: {
fontFamily: 'roboto',
fontWeight: '300',
fontSize: '16px',
color: '#666666',
textAlign: 'center',
marginBottom: '30px'
},
actionButtons: {
display: 'flex',
gap: '15px',
justifyContent: 'center',
marginTop: '20px',
flexWrap: 'wrap'
},
uploadButton: {
borderRadius: '25px',
padding: '12px 30px',
fontSize: '16px',
fontWeight: '500',
textTransform: 'none',
background: 'linear-gradient(45deg, #4CAF50 30%, #45a049 90%)',
color: 'white',
'&:hover': {
background: 'linear-gradient(45deg, #45a049 30%, #4CAF50 90%)',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.3)'
},
'&:disabled': {
background: '#cccccc',
color: '#666666'
}
},
clearButton: {
borderRadius: '25px',
padding: '12px 30px',
fontSize: '16px',
fontWeight: '500',
textTransform: 'none',
border: '2px solid #f44336',
color: '#f44336',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#f44336',
color: 'white',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(244, 67, 54, 0.3)'
}
}
});
function MultiUploadPage() {
const classes = useStyles();
const [selectedImages, setSelectedImages] = useState([]);
const [metadata, setMetadata] = useState({
year: new Date().getFullYear(),
title: '',
description: '',
name: ''
});
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const handleImagesSelected = (newImages) => {
console.log('handleImagesSelected called with:', newImages);
setSelectedImages(prev => {
const updated = [...prev, ...newImages];
console.log('Updated selected images:', updated);
return updated;
});
};
const handleRemoveImage = (indexToRemove) => {
setSelectedImages(prev =>
prev.filter((_, index) => index !== indexToRemove)
);
};
const handleClearAll = () => {
setSelectedImages([]);
setMetadata({
year: new Date().getFullYear(),
title: '',
description: '',
name: ''
});
};
const handleUpload = async () => {
if (selectedImages.length === 0) {
Swal.fire({
icon: 'warning',
title: 'Keine Bilder ausgewählt',
text: 'Bitte wählen Sie mindestens ein Bild zum Upload aus.',
confirmButtonColor: '#4CAF50'
});
return;
}
if (!metadata.year || !metadata.title.trim()) {
Swal.fire({
icon: 'warning',
title: 'Pflichtfelder fehlen',
text: 'Bitte geben Sie Jahr und Titel an.',
confirmButtonColor: '#4CAF50'
});
return;
}
setUploading(true);
setUploadProgress(0);
try {
// Simuliere Progress (da wir noch keinen echten Progress haben)
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return 90;
}
return prev + 10;
});
}, 200);
const result = await uploadImageBatch(selectedImages, metadata);
clearInterval(progressInterval);
setUploadProgress(100);
// Kurze Verzögerung für UX
setTimeout(() => {
setUploading(false);
Swal.fire({
icon: 'success',
title: 'Upload erfolgreich!',
text: `${result.imageCount} Bild${result.imageCount !== 1 ? 'er' : ''} wurden hochgeladen.`,
confirmButtonColor: '#4CAF50',
timer: 2000,
showConfirmButton: false
});
// Seite neu laden für nächsten Upload
setTimeout(() => {
window.location.reload();
}, 2000);
}, 500);
} catch (error) {
setUploading(false);
console.error('Upload error:', error);
Swal.fire({
icon: 'error',
title: 'Upload fehlgeschlagen',
text: error.message || 'Ein Fehler ist beim Upload aufgetreten.',
confirmButtonColor: '#f44336'
});
}
};
return (
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" className={classes.container}>
<Card className={classes.card}>
<CardContent>
<Typography className={classes.headerText}>
Project Image Uploader
</Typography>
<Typography className={classes.subheaderText}>
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe es in wenigen Worten.
<br />
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
</Typography>
{!uploading ? (
<>
<MultiImageDropzone
onImagesSelected={handleImagesSelected}
selectedImages={selectedImages}
/>
<ImagePreviewGallery
images={selectedImages}
onRemoveImage={handleRemoveImage}
/>
{selectedImages.length > 0 && (
<>
<DescriptionInput
metadata={metadata}
onMetadataChange={setMetadata}
/>
<div className={classes.actionButtons}>
<Button
className={classes.uploadButton}
onClick={handleUpload}
disabled={uploading || selectedImages.length === 0}
size="large"
>
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
</Button>
<Button
className={classes.clearButton}
onClick={handleClearAll}
size="large"
>
🗑 Alle entfernen
</Button>
</div>
</>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Loading />
<UploadProgress
progress={uploadProgress}
totalFiles={selectedImages.length}
isUploading={uploading}
/>
</div>
)}
</CardContent>
</Card>
</Container>
<div className="footerContainer">
<Footer />
</div>
</div>
);
}
export default MultiUploadPage;

View File

@ -0,0 +1,313 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { makeStyles } from '@material-ui/core/styles';
import {
Typography,
Box,
CircularProgress,
IconButton
} from '@material-ui/core';
import {
Home as HomeIcon,
ExitToApp as ExitIcon
} from '@material-ui/icons';
// Utils
import { fetchAllGroups } from '../../Utils/batchUpload';
const useStyles = makeStyles({
fullscreenContainer: {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
overflow: 'hidden'
},
exitButton: {
position: 'absolute',
top: '20px',
right: '20px',
color: 'white',
backgroundColor: 'rgba(0,0,0,0.5)',
'&:hover': {
backgroundColor: 'rgba(0,0,0,0.8)'
}
},
homeButton: {
position: 'absolute',
top: '20px',
left: '20px',
color: 'white',
backgroundColor: 'rgba(0,0,0,0.5)',
'&:hover': {
backgroundColor: 'rgba(0,0,0,0.8)'
}
},
slideshowImage: {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
transition: 'opacity 0.5s ease-in-out'
},
descriptionContainer: {
position: 'fixed',
left: 40,
bottom: 40,
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)'
},
titleText: {
color: 'white',
fontSize: '28px',
fontWeight: '500',
margin: '0 0 8px 0',
fontFamily: 'roboto'
},
yearAuthorText: {
color: '#FFD700',
fontSize: '18px',
fontWeight: '400',
margin: '0 0 12px 0',
fontFamily: 'roboto'
},
descriptionText: {
color: '#E0E0E0',
fontSize: '16px',
fontWeight: '300',
margin: '0 0 12px 0',
fontFamily: 'roboto',
lineHeight: '1.4'
},
metaText: {
color: '#999',
fontSize: '12px',
marginTop: '8px',
fontFamily: 'roboto'
},
loadingContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'white'
}
});
function SlideshowPage() {
const classes = useStyles();
const history = useHistory();
const [allGroups, setAllGroups] = useState([]);
const [currentGroupIndex, setCurrentGroupIndex] = useState(0);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fadeOut, setFadeOut] = useState(false);
// Slideshow-Timing Konstanten
const IMAGE_DISPLAY_TIME = 4000; // 4 Sekunden pro Bild
const TRANSITION_TIME = 500; // 0.5 Sekunden für Fade-Effekt
// Gruppen laden
useEffect(() => {
const loadAllGroups = async () => {
try {
setLoading(true);
const groupsData = await fetchAllGroups();
if (groupsData.groups && groupsData.groups.length > 0) {
// Mische die Gruppen zufällig
const shuffledGroups = [...groupsData.groups].sort(() => Math.random() - 0.5);
setAllGroups(shuffledGroups);
setCurrentGroupIndex(0);
setCurrentImageIndex(0);
} else {
setError('Keine Slideshows gefunden');
}
} catch (err) {
console.error('Fehler beim Laden der Gruppen:', err);
setError('Fehler beim Laden der Slideshows');
} finally {
setLoading(false);
}
};
loadAllGroups();
}, []);
// Automatischer Slideshow-Wechsel
const nextImage = useCallback(() => {
if (allGroups.length === 0) return;
const currentGroup = allGroups[currentGroupIndex];
if (!currentGroup || !currentGroup.images) return;
setFadeOut(true);
setTimeout(() => {
if (currentImageIndex + 1 < currentGroup.images.length) {
// Nächstes Bild in der aktuellen Gruppe
setCurrentImageIndex(prev => prev + 1);
} else {
// Zur nächsten Gruppe wechseln (zufällig)
const nextGroupIndex = Math.floor(Math.random() * allGroups.length);
setCurrentGroupIndex(nextGroupIndex);
setCurrentImageIndex(0);
}
setFadeOut(false);
}, TRANSITION_TIME);
}, [allGroups, currentGroupIndex, currentImageIndex]);
// Timer für automatischen Wechsel
useEffect(() => {
if (loading || error || allGroups.length === 0) return;
const timer = setInterval(nextImage, IMAGE_DISPLAY_TIME);
return () => clearInterval(timer);
}, [loading, error, allGroups, nextImage]);
// Keyboard-Navigation
useEffect(() => {
const handleKeyPress = (event) => {
switch (event.key) {
case 'Escape':
history.push('/');
break;
case ' ':
case 'ArrowRight':
nextImage();
break;
default:
break;
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [nextImage, history]);
// Aktuelle Gruppe und Bild
const currentGroup = allGroups[currentGroupIndex];
const currentImage = currentGroup?.images?.[currentImageIndex];
if (loading) {
return (
<Box className={classes.fullscreenContainer}>
<Box className={classes.loadingContainer}>
<CircularProgress style={{ color: 'white', marginBottom: '20px' }} />
<Typography style={{ color: 'white' }}>Slideshow wird geladen...</Typography>
</Box>
</Box>
);
}
if (error) {
return (
<Box className={classes.fullscreenContainer}>
<Box className={classes.loadingContainer}>
<Typography style={{ color: 'white', fontSize: '24px' }}>{error}</Typography>
<IconButton
className={classes.homeButton}
onClick={() => history.push('/')}
title="Zur Startseite"
>
<HomeIcon />
</IconButton>
</Box>
</Box>
);
}
if (!currentGroup || !currentImage) {
return (
<Box className={classes.fullscreenContainer}>
<Box className={classes.loadingContainer}>
<Typography style={{ color: 'white', fontSize: '24px' }}>
Keine Bilder verfügbar
</Typography>
<IconButton
className={classes.homeButton}
onClick={() => history.push('/')}
title="Zur Startseite"
>
<HomeIcon />
</IconButton>
</Box>
</Box>
);
}
return (
<Box className={classes.fullscreenContainer}>
{/* Navigation Buttons */}
<IconButton
className={classes.homeButton}
onClick={() => history.push('/')}
title="Zur Startseite"
>
<HomeIcon />
</IconButton>
<IconButton
className={classes.exitButton}
onClick={() => history.push('/')}
title="Slideshow beenden"
>
<ExitIcon />
</IconButton>
{/* Hauptbild */}
<img
src={`/api${currentImage.filePath}`}
alt={currentImage.originalName}
className={classes.slideshowImage}
style={{
opacity: fadeOut ? 0 : 1,
transition: `opacity ${TRANSITION_TIME}ms ease-in-out`
}}
/>
{/* Beschreibung */}
<Box className={classes.descriptionContainer}>
{/* Titel */}
<Typography className={classes.titleText}>
{currentGroup.title || 'Unbenanntes Projekt'}
</Typography>
{/* Jahr und Name */}
<Typography className={classes.yearAuthorText}>
{currentGroup.year}
{currentGroup.name && `${currentGroup.name}`}
</Typography>
{/* Beschreibung (wenn vorhanden) */}
{currentGroup.description && (
<Typography className={classes.descriptionText}>
{currentGroup.description}
</Typography>
)}
{/* Meta-Informationen */}
<Typography className={classes.metaText}>
Bild {currentImageIndex + 1} von {currentGroup.images.length}
Slideshow {currentGroupIndex + 1} von {allGroups.length}
</Typography>
</Box>
</Box>
);
}
export default SlideshowPage;

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react'
import '../../App.css'
import Footer from '../ComponentUtils/Footer'
import ImageUploadCard from '../ComponentUtils/ImageUploadCard'
import Navbar from '../ComponentUtils/Headers/Navbar'
import { useHistory } from "react-router-dom";
import { Button, Container, Box } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import Swal from 'sweetalert2/dist/sweetalert2.js'
import 'sweetalert2/src/sweetalert2.scss'
import { sendRequest } from '../../Utils/sendRequest'
import '../ComponentUtils/Css/Background.css'
const useStyles = makeStyles({
multiUploadButton: {
borderRadius: '25px',
padding: '12px 30px',
fontSize: '16px',
fontWeight: '500',
textTransform: 'none',
background: 'linear-gradient(45deg, #2196F3 30%, #1976D2 90%)',
color: 'white',
marginTop: '20px',
'&:hover': {
background: 'linear-gradient(45deg, #1976D2 30%, #2196F3 90%)',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(33, 150, 243, 0.3)'
}
},
buttonContainer: {
textAlign: 'center',
marginTop: '20px',
marginBottom: '20px'
}
});
function UploadPage() {
const classes = useStyles();
// History for pushing to a new link after uploading image
const history = useHistory();
const [loading, setLoading] = useState(false)
const handleLoading = () => {
setLoading(true)
}
const handleResponse = (value) => {
// Router push to uploadd page
setTimeout(() => {
setLoading(false)
history.push(value.data.filePath)
Swal.fire({
icon: 'success',
title: "Your image was uploaded!",
showConfirmButton: false,
timer: 1500
})
}, 1400)
}
const handlePaste = (event) => {
const fileUploaded = event.clipboardData.files[0]
sendRequest(fileUploaded, handleLoading, handleResponse)
}
const handleMultiUpload = () => {
history.push('/multi-upload')
}
return (
<div className="allContainer" onPaste={handlePaste}>
<Navbar />
<ImageUploadCard handleLoading={handleLoading} handleResponse={handleResponse} loading={loading}/>
<Container maxWidth="sm">
<div className={classes.buttonContainer}>
<Button
className={classes.multiUploadButton}
onClick={handleMultiUpload}
size="large"
>
📸 Mehrere Bilder hochladen
</Button>
</div>
</Container>
<div className="footerContainer">
<Footer />
</div>
</div>
)
}
export default UploadPage

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react'
import { useParams } from 'react-router'
import './Css/UploadedImagePage.css'
// Components
import UploadedImage from '../ComponentUtils/UploadedImage'
import Footer from '../ComponentUtils/Footer'
import Navbar from '../ComponentUtils/Headers/Navbar'
import SocialMediaShareButtons from '../ComponentUtils/SocialMedia/SocialMediaShareButtons'
import FZF from './404Page'
function UploadedImagePage() {
// Get the uploaded image url by url
const { image_url } = useParams()
const [imageFound, setImageFound] = useState(true)
return (
<div>
{imageFound?
<div className="allContainer">
<Navbar />
<div className="rootUploadWrap">
<UploadedImage image_url={image_url} imageNotFound={() => setImageFound(false)}/>
<SocialMediaShareButtons image_url={image_url}/>
</div>
<Footer />
</div>
:
<FZF />
}
</div>
)
}
export default UploadedImagePage

BIN
frontend/src/Images/404.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(199, 225, 243);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(184, 207, 230);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(169, 190, 218);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(154, 173, 206);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(139, 155, 193);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(125, 138, 181);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(110, 121, 169);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(95, 103, 156);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(80, 86, 144);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(65, 69, 132);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(50, 51, 119);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(35, 34, 107);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(63, 25, 106);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(81, 46, 107);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(98, 66, 107);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(116, 87, 108);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(133, 107, 109);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(151, 128, 110);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(168, 148, 110);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(185, 168, 111);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(203, 189, 112);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(220, 209, 113);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(238, 230, 113);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(255, 250, 114);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(101, 32, 96);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(96, 47, 106);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(92, 63, 115);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(87, 79, 124);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(83, 94, 133);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(79, 110, 143);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(74, 126, 152);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(70, 141, 161);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(65, 157, 170);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(61, 173, 180);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(56, 188, 189);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(52, 204, 198);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(21, 26, 128);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(42, 42, 132);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(64, 58, 137);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(85, 74, 142);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(106, 90, 146);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(128, 106, 151);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(149, 121, 156);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(170, 137, 160);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(191, 153, 165);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(213, 169, 170);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(234, 185, 174);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(255, 201, 179);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(15, 34, 94);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(27, 52, 108);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(39, 69, 122);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(51, 87, 136);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(63, 104, 150);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(75, 122, 165);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(86, 139, 179);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(98, 156, 193);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(110, 174, 207);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(122, 191, 221);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(134, 209, 235);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(146, 226, 249);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(241, 218, 223);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(234, 198, 208);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(226, 179, 194);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(218, 159, 179);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(210, 139, 164);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(203, 119, 149);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(195, 99, 134);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(187, 79, 119);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(179, 60, 105);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(172, 40, 90);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(164, 20, 75);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(156, 0, 60);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) " style="position: relative; z-index: 0; fill: rgb(214, 242, 255);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) " style="position: relative; z-index: 1; fill: rgb(199, 225, 243);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) " style="position: relative; z-index: 2; fill: rgb(184, 207, 230);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) " style="position: relative; z-index: 3; fill: rgb(169, 190, 218);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) " style="position: relative; z-index: 4; fill: rgb(154, 173, 206);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) " style="position: relative; z-index: 5; fill: rgb(139, 155, 193);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) " style="position: relative; z-index: 6; fill: rgb(125, 138, 181);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) " style="position: relative; z-index: 7; fill: rgb(110, 121, 169);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) " style="position: relative; z-index: 8; fill: rgb(95, 103, 156);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) " style="position: relative; z-index: 9; fill: rgb(80, 86, 144);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) " style="position: relative; z-index: 10; fill: rgb(65, 69, 132);"></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) " style="position: relative; z-index: 11; fill: rgb(50, 51, 119);"></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

Some files were not shown because too many files have changed in this diff Show More