Initial Commit
552
ERWEITERUNG.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
upload/
|
||||
1
backend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
REMOVE_IMAGES=<boolean | undefined>
|
||||
41
backend/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 };
|
||||
202
backend/src/database/DatabaseManager.js
Normal 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;
|
||||
48
backend/src/database/schema.sql
Normal 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
|
|
@ -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();
|
||||
})();
|
||||
13
backend/src/middlewares/cors.js
Normal 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;
|
||||
12
backend/src/middlewares/index.js
Normal 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 };
|
||||
52
backend/src/models/uploadGroup.js
Normal 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;
|
||||
340
backend/src/repositories/GroupRepository.js
Normal 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();
|
||||
108
backend/src/routes/batchUpload.js
Normal 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;
|
||||
10
backend/src/routes/download.js
Normal 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;
|
||||
199
backend/src/routes/groups.js
Normal 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;
|
||||
11
backend/src/routes/index.js
Normal 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 };
|
||||
75
backend/src/routes/migration.js
Normal 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;
|
||||
32
backend/src/routes/upload.js
Normal 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
|
|
@ -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;
|
||||
207
backend/src/services/MigrationService.js
Normal 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();
|
||||
71
backend/src/utils/groupStorage.js
Normal 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;
|
||||
24
backend/src/utils/initiate-resources.js
Normal 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;
|
||||
3
backend/src/utils/load-env.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const path = require('path');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env')});
|
||||
10
backend/src/utils/remove-images.js
Normal 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
|
|
@ -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
|
||||
BIN
docs/images/example-video.gif
Normal file
|
After Width: | Height: | Size: 857 KiB |
BIN
docs/images/logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/images/screenshot.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
docs/images/vallezw-Image-Uploader-dark.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/images/vallezw-Image-Uploader-light.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
2
frontend/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
API_URL=http://localhost
|
||||
CLIENT_URL=http://localhost
|
||||
25
frontend/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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)
|
||||
106
frontend/conf/conf.d/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
24
frontend/conf/conf.d/gzip.conf
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
window._env_ = {
|
||||
API_URL: "http://localhost:5000",
|
||||
CLIENT_URL: "http://localhost",
|
||||
}
|
||||
29
frontend/env.sh
Executable 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
|
|
@ -0,0 +1 @@
|
|||
admin:$apr1$q2zv8h0V$ueMqnKIeQU6NN1YnHWNVe/
|
||||
33428
frontend/package-lock.json
generated
Normal file
49
frontend/package.json
Normal 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
|
After Width: | Height: | Size: 2.2 KiB |
36
frontend/public/index.html
Normal 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
|
After Width: | Height: | Size: 714 B |
BIN
frontend/public/logo-180.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo-192.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/logo-32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/public/logo-512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
25
frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
6
frontend/public/robots.txt
Normal 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
|
|
@ -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
|
|
@ -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;
|
||||
23
frontend/src/Components/ComponentUtils/Css/Background.css
Normal 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;
|
||||
}
|
||||
25
frontend/src/Components/ComponentUtils/Css/Footer.css
Normal 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;
|
||||
|
||||
}
|
||||
109
frontend/src/Components/ComponentUtils/Css/Image.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
48
frontend/src/Components/ComponentUtils/Css/Image.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
*/
|
||||
137
frontend/src/Components/ComponentUtils/Css/Navbar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
13
frontend/src/Components/ComponentUtils/Footer.js
Normal 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
|
||||
25
frontend/src/Components/ComponentUtils/Headers/Navbar.js
Normal 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
|
||||
73
frontend/src/Components/ComponentUtils/ImageUploadCard.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
96
frontend/src/Components/ComponentUtils/StyledDropzone.js
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/Components/ComponentUtils/UploadButton.js
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
43
frontend/src/Components/ComponentUtils/UploadedImage.js
Normal 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 })}>
|
||||
×
|
||||
</span>
|
||||
<img className="modal-content" id="img01" src={this.state.modalSrc} alt="Uploaded"/>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
frontend/src/Components/Pages/404Page.js
Normal file
68
frontend/src/Components/Pages/Css/404Page.css
Normal 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;
|
||||
}
|
||||
360
frontend/src/Components/Pages/Css/ModerationPage.css
Normal 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));
|
||||
}
|
||||
}
|
||||
19
frontend/src/Components/Pages/Css/UploadedImagePage.css
Normal 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;
|
||||
}
|
||||
349
frontend/src/Components/Pages/GroupsOverviewPage.js
Normal 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;
|
||||
346
frontend/src/Components/Pages/ModerationPage.js
Normal 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;
|
||||
284
frontend/src/Components/Pages/MultiUploadPage.js
Normal 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;
|
||||
313
frontend/src/Components/Pages/SlideshowPage.js
Normal 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;
|
||||
103
frontend/src/Components/Pages/UploadPage.js
Normal 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
|
||||
38
frontend/src/Components/Pages/UploadedImagePage.js
Normal 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
|
After Width: | Height: | Size: 75 KiB |
1
frontend/src/Images/404.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
1
frontend/src/Images/404_blue.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
1
frontend/src/Images/404_green.svg
Normal file
|
After Width: | Height: | Size: 21 KiB |
3
frontend/src/Images/Backgrounds/bg1.svg
Normal 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 |
3
frontend/src/Images/Backgrounds/bg2.svg
Normal 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 |
3
frontend/src/Images/Backgrounds/bg3.svg
Normal 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 |
3
frontend/src/Images/Backgrounds/bg4.svg
Normal 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 |
3
frontend/src/Images/Backgrounds/bg5.svg
Normal 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 |
3
frontend/src/Images/Backgrounds/bg6.svg
Normal 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 |
3
frontend/src/Images/background.svg
Normal 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 |
BIN
frontend/src/Images/going_up.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
1
frontend/src/Images/going_up.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |