feat: Complete frontend refactoring and development environment setup
Major Frontend Refactoring: - Replace ImagePreviewGallery with unified ImageGallery/ImageGalleryCard components - Support 4 display modes: group, moderation, preview, single-image - Add hidePreview prop to conditionally hide group preview images - Unified grid layout with responsive 3/2/1 column design - Remove 15+ legacy files and components - Delete UploadedImagePage, SocialMedia components, old upload components - Remove unused CSS files (GroupCard.css, Image.css/scss) - Clean up /upload/:image_url route from App.js - Fix image preview functionality in MultiUploadPage - Convert File objects to blob URLs with URL.createObjectURL() - Add proper memory cleanup with URL.revokeObjectURL() - Improve page navigation and layout - Fix GroupsOverviewPage to route to /groups/:groupId detail page - Adjust PublicGroupImagesPage spacing and layout - Fix ModerationGroupsPage duplicate stats section CSS Refactoring: - Rename GroupCard.css → ImageGallery.css with updated class names - Maintain backward compatibility with legacy class names - Fix grid stretching with fixed 3-column layout Development Environment: - Add docker-compose.override.yml for local development - Create Dockerfile.dev with hot-reload support - Add start-dev.sh and nginx.dev.conf - Update README.dev.md with development setup instructions Production Build: - Fix frontend/Dockerfile multi-stage build (as → AS) - Update prod.sh to explicitly use docker-compose.yml (ignore override) - Resolve node:18-alpine image corruption issue - Backend Dockerfile improvements for Node 14 compatibility Documentation: - Update TODO.md marking completed frontend tasks - Clean up docs/images directory - Update README.md with current project status All changes tested and verified in both development and production environments.
This commit is contained in:
parent
237c776ddc
commit
a0d74f795a
52
README.dev.md
Normal file
52
README.dev.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
## Dev: Schnellstart
|
||||||
|
|
||||||
|
Kurz und knapp — so startest und nutzt du die lokale Dev‑Umgebung mit HMR (nginx als Proxy vor dem CRA dev server):
|
||||||
|
|
||||||
|
Voraussetzungen
|
||||||
|
- Docker & Docker Compose (Docker Compose Plugin)
|
||||||
|
|
||||||
|
Starten (Dev)
|
||||||
|
1. Build & Start (daemon):
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d image-uploader-frontend
|
||||||
|
```
|
||||||
|
2. Logs verfolgen:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f image-uploader-frontend
|
||||||
|
```
|
||||||
|
3. Browser öffnen: http://localhost:3000 (HMR aktiv)
|
||||||
|
|
||||||
|
Ändern & Testen
|
||||||
|
- Dateien editieren im `frontend/src/...` → HMR übernimmt Änderungen sofort.
|
||||||
|
- Wenn du nginx‑Konfiguration anpassen willst, editiere `frontend/conf/conf.d/default.conf` (Dev‑Variante wird beim Containerstart benutzt). Nach Änderung: nginx reload ohne Neustart:
|
||||||
|
```bash
|
||||||
|
docker compose exec image-uploader-frontend nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Probleme mit `node_modules`
|
||||||
|
- Wenn du ein host‑seitiges `frontend/node_modules` hast, lösche es (konsistenter ist der container‑verwaltete Volume):
|
||||||
|
```bash
|
||||||
|
rm -rf frontend/node_modules
|
||||||
|
```
|
||||||
|
Danach `docker compose up --build -d image-uploader-frontend` erneut ausführen.
|
||||||
|
|
||||||
|
Stoppen
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis
|
||||||
|
- Diese Dev‑Konfiguration läuft lokal mit erweiterten Rechten (nur für Entwicklung). Produktions‑Images/Configs bleiben unverändert.
|
||||||
|
|
||||||
|
|
||||||
|
Build and start:
|
||||||
|
docker compose up --build -d image-uploader-frontend
|
||||||
|
|
||||||
|
Tail logs:
|
||||||
|
docker compose logs -f image-uploader-frontend
|
||||||
|
|
||||||
|
Reload nginx (after editing conf in container):
|
||||||
|
docker compose exec image-uploader-frontend nginx -s reload
|
||||||
|
|
||||||
|
docker compose exec image-uploader-frontend nginx -s reload
|
||||||
|
docker compose down
|
||||||
170
README.md
170
README.md
|
|
@ -29,41 +29,45 @@ This project extends the original [Image-Uploader by vallezw](https://github.com
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frontend:
|
image-uploader-frontend:
|
||||||
image: vallezw/image-uploader-client
|
image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-frontend:latest
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- "image-uploader-backend"
|
||||||
environment:
|
environment:
|
||||||
- "API_URL=http://localhost:5000"
|
- "API_URL=http://image-uploader-backend:5000"
|
||||||
- "CLIENT_URL=http://localhost"
|
- "CLIENT_URL=http://localhost"
|
||||||
container_name: frontend
|
container_name: "image-uploader-frontend"
|
||||||
backend:
|
networks:
|
||||||
image: vallezw/image-uploader-backend
|
- npm-nw
|
||||||
environment:
|
- image-uploader-internal
|
||||||
- "CLIENT_URL=http://localhost"
|
|
||||||
container_name: backend
|
image-uploader-backend:
|
||||||
backend:
|
image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-backend:latest
|
||||||
image: vallezw/image-uploader-client
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
container_name: frontend
|
|
||||||
image: vallezw/image-uploader-backend
|
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
container_name: backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
container_name: "image-uploader-backend"
|
||||||
|
networks:
|
||||||
|
- image-uploader-internal
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/usr/src/app/src/upload
|
|
||||||
depends_on:
|
|
||||||
- app-data:/usr/src/app/src/data
|
- app-data:/usr/src/app/src/data
|
||||||
- backend
|
|
||||||
volumes:
|
volumes:
|
||||||
app-data:
|
app-data:
|
||||||
environment:
|
|
||||||
- "API_URL=http://localhost:5000"
|
|
||||||
- "CLIENT_URL=http://localhost"
|
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
npm-nw:
|
||||||
|
external: true
|
||||||
|
image-uploader-internal:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Start the application**:
|
2. **Start the application**:
|
||||||
|
|
@ -113,68 +117,66 @@ docker compose up -d
|
||||||
- View group details (title, creator, description, image count)
|
- View group details (title, creator, description, image count)
|
||||||
- Bulk moderation actions
|
- Bulk moderation actions
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Public Overview of all approved slideshows
|
||||||
- **Group Management**: Navigate to `http://localhost/groups`
|
- **Group Management**: Navigate to `http://localhost/groups`
|
||||||
- Overview of all approved slideshow collections
|
- Overview of all approved slideshow collections
|
||||||
- Launch slideshow mode from any group
|
- Launch slideshow mode from any group
|
||||||
- View group statistics and metadata
|
- 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
|
## Data Structure
|
||||||
### Slideshow JSON Format
|
|
||||||
|
|
||||||
```json
|
Data are stored in sqlite database. The structure is as follows:
|
||||||
[
|
``` sql
|
||||||
{
|
CREATE TABLE groups (
|
||||||
"groupId": "0fSwazTOU",
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
"description": "My Photo Collection",
|
group_id TEXT UNIQUE NOT NULL,
|
||||||
"uploadDate": "2025-10-11T14:34:48.159Z",
|
year INTEGER NOT NULL,
|
||||||
"images":
|
title TEXT NOT NULL,
|
||||||
{
|
description TEXT,
|
||||||
"fileName": "ZMmHXzHbqw.jpg",
|
name TEXT,
|
||||||
"originalName": "vacation-photo-1.jpg",
|
upload_date DATETIME NOT NULL,
|
||||||
"filePath": "/upload/ZMmHXzHbqw.jpg",
|
approved BOOLEAN DEFAULT FALSE,
|
||||||
"uploadOrder": 1
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
},
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
{
|
);
|
||||||
"fileName": "tjjnngOmXS.jpg",
|
CREATE TABLE sqlite_sequence(name,seq);
|
||||||
"originalName": "vacation-photo-2.jpg",
|
CREATE TABLE images (
|
||||||
"filePath": "/upload/tjjnngOmXS.jpg",
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
"uploadOrder": 2
|
group_id TEXT NOT NULL,
|
||||||
}
|
file_name TEXT NOT NULL,
|
||||||
],
|
original_name TEXT NOT NULL,
|
||||||
"imageCount": 21
|
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
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_groups_group_id ON groups(group_id);
|
||||||
|
CREATE INDEX idx_groups_year ON groups(year);
|
||||||
|
CREATE INDEX idx_groups_upload_date ON groups(upload_date);
|
||||||
|
CREATE INDEX idx_images_group_id ON images(group_id);
|
||||||
|
CREATE INDEX idx_images_upload_order ON images(upload_order);
|
||||||
|
CREATE TRIGGER update_groups_timestamp
|
||||||
|
AFTER UPDATE ON groups
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
## Architecture
|
||||||
### Backend (Node.js + Express)
|
### Backend (Node.js + Express)
|
||||||
- **Multi-upload API**: `/api/upload/batch` - Handles batch file processing
|
- **Multi-upload API**: `/api/upload/batch` - Handles batch file processing
|
||||||
- **Groups API**: `/api/groups` - Retrieves slideshow collections
|
- **Groups API**: `/api/groups` - Retrieves slideshow collections
|
||||||
- **File Storage**: Organized in `/upload` directory
|
- **File Storage**: Organized in `/upload` directory
|
||||||
- **Metadata Storage**: JSON files in `/data` directory
|
- **Database Storage**: sqlite database in `/app/src/data/db/image_uploader.db`
|
||||||
|
|
||||||
### Frontend (React + Material-UI)
|
### Frontend (React + Material-UI)
|
||||||
|
|
||||||
|
|
@ -190,12 +192,17 @@ docker compose up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
Docker Volume (app-data)
|
Docker Volume (app-data)
|
||||||
├── upload/
|
src
|
||||||
|
└── app
|
||||||
|
├── src
|
||||||
|
├── upload
|
||||||
│ ├── ZMmHXzHbqw.jpg
|
│ ├── ZMmHXzHbqw.jpg
|
||||||
│ ├── tjjnngOmXS.jpg
|
│ ├── tjjnngOmXS.jpg
|
||||||
│ └── ...### Slideshow JSON Format
|
│ └── ...
|
||||||
└── data/ # Metadata
|
└── data
|
||||||
└── upload-groups.json
|
└── db
|
||||||
|
└── image_uploader.db
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hosting it with Docker
|
### Hosting it with Docker
|
||||||
|
|
@ -233,24 +240,9 @@ Docker Volume (app-data)
|
||||||
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
|
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
|
||||||
|
|
||||||
### Volume Configuration
|
### 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
|
- **Upload Limits**: 100MB maximum file size for batch uploads
|
||||||
- **Supported Formats**: JPG, JPEG, PNG, GIF, WebP
|
- **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 & Restore
|
||||||
#### Backup slideshow data
|
#### Backup slideshow data
|
||||||
|
|
|
||||||
25
TODO.md
25
TODO.md
|
|
@ -2,19 +2,19 @@
|
||||||
|
|
||||||
Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich pflege sie, sobald ich Aufgaben erledige.
|
Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich pflege sie, sobald ich Aufgaben erledige.
|
||||||
|
|
||||||
## Aktuelle Aufgaben
|
## Anstehende Aufgaben
|
||||||
|
|
||||||
- [ ] CSS-Sweep: Duplikate finden und Regeln in `frontend/src/app.css` zentralisieren
|
### Frontend
|
||||||
- [ ] Page-CSS bereinigen: Entfernen von Regeln, die jetzt in `frontend/src/app.css` sind
|
|
||||||
- [ ] README: Kurzbeschreibung des Style-Guides und wo zentrale Klassen liegen
|
- [ ] Code Cleanup & Refactoring
|
||||||
|
- [ ] Überprüfung der Komponentenstruktur
|
||||||
|
- [ ] Entfernen ungenutzter Dateien
|
||||||
|
- [x] Vereinheitlichung der ImageGallery Komponente:
|
||||||
|
- [x] `ImagePreviewGallery` und 'GroupCard' zusammenführen
|
||||||
|
- [x] Die neue Komponente soll ImageGallery heißen. Sie besteht aus einem Grid aus "GroupCard" die `GroupCard` soll zukünfig `ImageGalleryCard`heißen.
|
||||||
|
- [x] Die neue Komponente `ImageGallery` soll so aussehen wir die GroupCard im Grid -> siehe Pages/ModerationGroupPage.js und Pages/GroupOverviewPage.js und auch die gleichen Funktionalitäten besitzen.
|
||||||
|
- [x] Klärung SimpleMultiDropzone vs. MultiImageUploadDropzone (MultiImageUploadDropzone wurde gelösch, SimpleMultiDropzone umbenannt in MultiImageDropzone)
|
||||||
- [ ] Persistentes Reordering: Drag-and-drop in `ImagePreviewGallery` + Backend-Endpunkt
|
- [ ] Persistentes Reordering: Drag-and-drop in `ImagePreviewGallery` + Backend-Endpunkt
|
||||||
- [ ] Kleine Smoke-Tests: Frontend-Build lokal laufen lassen und UI quick-check
|
|
||||||
|
|
||||||
## Erledigte Aufgaben
|
|
||||||
- [x] Alte Dateien entfernt (`ModerationPage.js`, alte `GroupImagesPage.js`)
|
|
||||||
- [x] Moderation-Detailseite angepasst (zeigt jetzt nur Bilder, Metadaten-Editor und Save/Back)
|
|
||||||
- [x] Routing: `/moderation` und `/moderation/groups/:groupId` sowie `/groups/:groupId` (public) gesetzt
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Zusätzliche Funktionen
|
# Zusätzliche Funktionen
|
||||||
|
|
@ -31,7 +31,6 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich p
|
||||||
|
|
||||||
## 🚀 Deployment-Überlegungen
|
## 🚀 Deployment-Überlegungen
|
||||||
|
|
||||||
|
|
||||||
### Speicher-Management
|
### Speicher-Management
|
||||||
- **Komprimierung**: Automatische Bildkomprimierung für große Dateien
|
- **Komprimierung**: Automatische Bildkomprimierung für große Dateien
|
||||||
|
|
||||||
|
|
@ -39,7 +38,6 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich p
|
||||||
- **File-Type Validation**: Nur erlaubte Bildformate
|
- **File-Type Validation**: Nur erlaubte Bildformate
|
||||||
- **Virus-Scanning**: Optional für Produktionsumgebung
|
- **Virus-Scanning**: Optional für Produktionsumgebung
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📈 Erweiterungs-Möglichkeiten (Zukunft)
|
## 📈 Erweiterungs-Möglichkeiten (Zukunft)
|
||||||
|
|
@ -54,7 +52,6 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich p
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## 🎯 Erfolgskriterien
|
## 🎯 Erfolgskriterien
|
||||||
|
|
||||||
### Must-Have
|
### Must-Have
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ FROM node:14
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Fix Debian Buster repositories (EOL)
|
||||||
|
RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list && \
|
||||||
|
sed -i 's/security.debian.org/archive.debian.org/g' /etc/apt/sources.list && \
|
||||||
|
sed -i '/stretch-updates/d' /etc/apt/sources.list
|
||||||
|
|
||||||
|
# Install sqlite3 CLI
|
||||||
|
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
|
||||||
52
docker-compose.override.yml
Normal file
52
docker-compose.override.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Development override to mount the frontend source into a node container
|
||||||
|
# and run the React dev server with HMR so you can edit files locally
|
||||||
|
# without rebuilding images. This file is intended to be used together
|
||||||
|
# with the existing docker-compose.yml from the repository.
|
||||||
|
|
||||||
|
services:
|
||||||
|
image-uploader-frontend:
|
||||||
|
container_name: image-uploader-frontend-dev
|
||||||
|
# For dev convenience nginx needs to be able to bind to port 80 and write the pid file
|
||||||
|
# and we also adjust file permissions on bind-mounted node_modules; run as root in dev.
|
||||||
|
user: root
|
||||||
|
# Build and run a development image that contains both nginx and the
|
||||||
|
# React dev server. nginx will act as a reverse proxy to the dev server
|
||||||
|
# so the app behaves more like production while HMR still works.
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
working_dir: /app
|
||||||
|
# Map host port 3000 to the nginx listener (container:80) so you can open
|
||||||
|
# http://localhost:3000 and see the nginx-served dev site.
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app:cached
|
||||||
|
# Keep container node_modules separate so host node_modules doesn't conflict
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
environment:
|
||||||
|
# Use the backend service name so the dev frontend (running in the same
|
||||||
|
# compose project) can reach the backend via the internal docker network.
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- API_URL=http://image-uploader-backend:5000
|
||||||
|
- CLIENT_URL=http://localhost:3000
|
||||||
|
networks:
|
||||||
|
- npm-nw
|
||||||
|
- image-uploader-internal
|
||||||
|
depends_on:
|
||||||
|
- image-uploader-backend
|
||||||
|
# The Dockerfile.dev provides a proper CMD that starts nginx and the
|
||||||
|
# react dev server; no ad-hoc command is required here.
|
||||||
|
|
||||||
|
networks:
|
||||||
|
npm-nw:
|
||||||
|
external: true
|
||||||
|
image-uploader-internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
|
driver: local
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 857 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
|
|
@ -1,5 +1,5 @@
|
||||||
# => Build container
|
# => Build container
|
||||||
FROM node:18-alpine as build
|
FROM node:18-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --silent
|
RUN npm install --silent
|
||||||
|
|
|
||||||
41
frontend/Dockerfile.dev
Normal file
41
frontend/Dockerfile.dev
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
FROM node:16-bullseye
|
||||||
|
|
||||||
|
# Install nginx and bash
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nginx procps bash ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user for dev
|
||||||
|
RUN useradd -m appuser || true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files first to leverage Docker cache for npm install
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY env.sh ./
|
||||||
|
COPY nginx.dev.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Make /app owned by the non-root user, then run npm as that user so
|
||||||
|
# node_modules are created with the correct owner and we avoid an expensive
|
||||||
|
# recursive chown later.
|
||||||
|
RUN chown appuser:appuser /app || true
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Install dependencies as non-root (faster overall because we avoid chown -R)
|
||||||
|
RUN npm ci --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
|
# Switch back to root to add the start script and adjust nginx paths
|
||||||
|
USER root
|
||||||
|
COPY start-dev.sh /start-dev.sh
|
||||||
|
RUN chmod +x /start-dev.sh
|
||||||
|
|
||||||
|
# Ensure nginx log/lib dirs are writable by the app user (small set)
|
||||||
|
RUN chown -R appuser:appuser /var/lib/nginx /var/log/nginx || true
|
||||||
|
# Remove default Debian nginx site so our dev config becomes the active default
|
||||||
|
RUN rm -f /etc/nginx/sites-enabled/default || true
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 80 3000
|
||||||
|
|
||||||
|
CMD ["/start-dev.sh"]
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# 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.backup
Normal file
106
frontend/conf/conf.d/default.conf.backup
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/nginx.dev.conf
Normal file
39
frontend/nginx.dev.conf
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Proxy requests to the CRA dev server so nginx can be used as reverse proxy
|
||||||
|
location /sockjs-node/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /sockjs-node {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# If a production build exists, serve static files directly for speed.
|
||||||
|
location /static/ {
|
||||||
|
alias /app/build/static/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/package-lock.json
generated
96
frontend/package-lock.json
generated
|
|
@ -5,6 +5,7 @@
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.11.3",
|
"@material-ui/core": "^4.11.3",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"react-code-blocks": "^0.0.8",
|
"react-code-blocks": "^0.0.8",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-dropzone": "^11.3.1",
|
"react-dropzone": "^11.3.1",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-lottie": "^1.2.3",
|
"react-lottie": "^1.2.3",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
|
|
@ -15040,6 +15042,27 @@
|
||||||
"version": "6.0.9",
|
"version": "6.0.9",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-helmet": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-fast-compare": "^3.1.1",
|
||||||
|
"react-side-effect": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.1",
|
"version": "17.0.1",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|
@ -15197,6 +15220,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-side-effect": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-syntax-highlighter": {
|
"node_modules/react-syntax-highlighter": {
|
||||||
"version": "12.2.1",
|
"version": "12.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -18040,18 +18072,6 @@
|
||||||
"is-typedarray": "^1.0.0"
|
"is-typedarray": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "3.9.9",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-plugin-styled-components": {
|
"node_modules/typescript-plugin-styled-components": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -21585,8 +21605,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@material-ui/types": {
|
"@material-ui/types": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"@material-ui/utils": {
|
"@material-ui/utils": {
|
||||||
"version": "4.11.2",
|
"version": "4.11.2",
|
||||||
|
|
@ -22328,8 +22347,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"acorn-jsx": {
|
"acorn-jsx": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"acorn-walk": {
|
"acorn-walk": {
|
||||||
"version": "7.2.0"
|
"version": "7.2.0"
|
||||||
|
|
@ -22361,12 +22379,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ajv-errors": {
|
"ajv-errors": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"ajv-keywords": {
|
"ajv-keywords": {
|
||||||
"version": "3.5.2",
|
"version": "3.5.2"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"alphanum-sort": {
|
"alphanum-sort": {
|
||||||
"version": "1.0.2"
|
"version": "1.0.2"
|
||||||
|
|
@ -22716,8 +22732,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"babel-plugin-named-asset-import": {
|
"babel-plugin-named-asset-import": {
|
||||||
"version": "0.3.7",
|
"version": "0.3.7"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"babel-plugin-polyfill-corejs2": {
|
"babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.1.10",
|
"version": "0.1.10",
|
||||||
|
|
@ -24828,8 +24843,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"eslint-plugin-react-hooks": {
|
"eslint-plugin-react-hooks": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"eslint-plugin-testing-library": {
|
"eslint-plugin-testing-library": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.1",
|
||||||
|
|
@ -27068,8 +27082,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest-pnp-resolver": {
|
"jest-pnp-resolver": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"jest-regex-util": {
|
"jest-regex-util": {
|
||||||
"version": "26.0.0"
|
"version": "26.0.0"
|
||||||
|
|
@ -29978,6 +29991,22 @@
|
||||||
"react-error-overlay": {
|
"react-error-overlay": {
|
||||||
"version": "6.0.9"
|
"version": "6.0.9"
|
||||||
},
|
},
|
||||||
|
"react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
||||||
|
},
|
||||||
|
"react-helmet": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
|
||||||
|
"requires": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-fast-compare": "^3.1.1",
|
||||||
|
"react-side-effect": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-is": {
|
"react-is": {
|
||||||
"version": "17.0.1"
|
"version": "17.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -30096,6 +30125,11 @@
|
||||||
"workbox-webpack-plugin": "5.1.4"
|
"workbox-webpack-plugin": "5.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-side-effect": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw=="
|
||||||
|
},
|
||||||
"react-syntax-highlighter": {
|
"react-syntax-highlighter": {
|
||||||
"version": "12.2.1",
|
"version": "12.2.1",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|
@ -31973,13 +32007,8 @@
|
||||||
"is-typedarray": "^1.0.0"
|
"is-typedarray": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typescript": {
|
|
||||||
"version": "3.9.9",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"typescript-plugin-styled-components": {
|
"typescript-plugin-styled-components": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"unbox-primitive": {
|
"unbox-primitive": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
@ -33372,8 +33401,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "7.4.4",
|
"version": "7.4.4"
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"xml-name-validator": {
|
"xml-name-validator": {
|
||||||
"version": "3.0.0"
|
"version": "3.0.0"
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
||||||
|
|
||||||
/* Page-specific styles for ModerationPage */
|
/* Page-specific styles for ModerationPage */
|
||||||
.moderation-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
.moderation-page h1 { text-align:center; color:#333; margin-bottom:30px; }
|
.moderation-content h1 { font-family: roboto; text-align:center; color:#333; margin-bottom:30px; }
|
||||||
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
||||||
.moderation-error { color:#dc3545; }
|
.moderation-error { color:#dc3545; }
|
||||||
|
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
.stat-number { display:block; font-size:2.5rem; font-weight:bold; color:#007bff; }
|
.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; }
|
.stat-label { display:block; font-size:0.9rem; color:#6c757d; margin-top:5px; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.moderation-section { margin-bottom:50px; }
|
.moderation-section { margin-bottom:50px; }
|
||||||
.moderation-section h2 { color:#333; border-bottom:2px solid #e9ecef; padding-bottom:10px; margin-bottom:25px; }
|
.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; }
|
.no-groups { text-align:center; color:#6c757d; font-style:italic; padding:30px; }
|
||||||
|
|
@ -47,42 +49,6 @@
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make cards use column layout so image + content align and cards stretch uniformly */
|
|
||||||
.group-card { display: flex; flex-direction: column; }
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* Support legacy class used by GroupsOverviewPage (CardMedia with class 'group-image') */
|
|
||||||
.group-card .group-image { width: 100%; height: 200px; object-fit: cover; display: block; }
|
|
||||||
|
|
||||||
/* Ensure content area expands to fill remaining space */
|
|
||||||
.group-content { flex-grow: 1; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
/* Utility classes to replace inline styles for consistent sizing */
|
|
||||||
.grid-item-stretch { display: flex; }
|
|
||||||
.card-stretch { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.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 */
|
/* Buttons */
|
||||||
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:0.85rem; transition:background-color 0.2s; flex:1; min-width:80px; }
|
.btn { padding: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 { background:#6c757d; color:white; }
|
||||||
|
|
@ -113,15 +79,11 @@
|
||||||
|
|
||||||
@media (max-width:768px) {
|
@media (max-width:768px) {
|
||||||
.moderation-stats { flex-direction:column; gap:20px; }
|
.moderation-stats { flex-direction:column; gap:20px; }
|
||||||
.groups-grid { grid-template-columns:1fr; }
|
|
||||||
.group-actions { flex-direction:column; }
|
|
||||||
.btn { width:100%; }
|
.btn { width:100%; }
|
||||||
.image-modal { max-width:95vw; max-height:95vh; }
|
.image-modal { max-width:95vw; max-height:95vh; }
|
||||||
.images-grid { grid-template-columns:repeat(auto-fit, minmax(150px,1fr)); }
|
.images-grid { grid-template-columns:repeat(auto-fit, minmax(150px,1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standard groups grid used by moderation and overview pages */
|
|
||||||
.groups-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
|
|
||||||
|
|
||||||
/* Common CTA / page-level utilities (moved from page CSS) */
|
/* Common CTA / page-level utilities (moved from page CSS) */
|
||||||
.view-button { border-radius: 20px; text-transform: none; font-size: 12px; padding: 6px 16px; background: linear-gradient(45deg, #4CAF50 30%, #45a049 90%); color: white; border: none; cursor: pointer; }
|
.view-button { border-radius: 20px; text-transform: none; font-size: 12px; padding: 6px 16px; background: linear-gradient(45deg, #4CAF50 30%, #45a049 90%); color: white; border: none; cursor: pointer; }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import './App.css';
|
||||||
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import UploadedImage from './Components/Pages/UploadedImagePage';
|
|
||||||
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
||||||
import SlideshowPage from './Components/Pages/SlideshowPage';
|
import SlideshowPage from './Components/Pages/SlideshowPage';
|
||||||
import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage';
|
import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage';
|
||||||
|
|
@ -16,7 +15,6 @@ function App() {
|
||||||
<Router>
|
<Router>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" exact component={MultiUploadPage} />
|
<Route path="/" exact component={MultiUploadPage} />
|
||||||
<Route path="/upload/:image_url" component={UploadedImage} />
|
|
||||||
<Route path="/slideshow" component={SlideshowPage} />
|
<Route path="/slideshow" component={SlideshowPage} />
|
||||||
<Route path="/groups/:groupId" component={PublicGroupImagesPage} />
|
<Route path="/groups/:groupId" component={PublicGroupImagesPage} />
|
||||||
<Route path="/groups" component={GroupsOverviewPage} />
|
<Route path="/groups" component={GroupsOverviewPage} />
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
#TODO: move GroudCars styles into this file
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
.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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
/*.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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
239
frontend/src/Components/ComponentUtils/Css/ImageGallery.css
Normal file
239
frontend/src/Components/ComponentUtils/Css/ImageGallery.css
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
/* ImageGallery and ImageGalleryCard Styles */
|
||||||
|
|
||||||
|
/* Make cards use column layout so image + content align and cards stretch uniformly */
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Base styles */
|
||||||
|
.image-gallery-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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card.pending {
|
||||||
|
border-left: 5px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card.approved {
|
||||||
|
border-left: 5px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Preview area */
|
||||||
|
.image-gallery-card-preview {
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support legacy class for backward compatibility */
|
||||||
|
.image-gallery-card .group-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Content area */
|
||||||
|
.image-gallery-card-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-info {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-info h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-meta {
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-description {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-card-upload-date {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Actions area */
|
||||||
|
.image-gallery-card-actions {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - No preview state */
|
||||||
|
.image-gallery-card-no-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Image count badge */
|
||||||
|
.image-gallery-card-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - Image order badge (preview mode) */
|
||||||
|
.image-gallery-card-image-order {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGalleryCard - File metadata */
|
||||||
|
.image-gallery-card-file-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes to replace inline styles for consistent sizing */
|
||||||
|
.grid-item-stretch {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stretch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGallery Grid Container */
|
||||||
|
.image-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ImageGallery Container */
|
||||||
|
.image-gallery-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-title {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: 2 columns on tablets */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.image-gallery-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: 1 column on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-gallery-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.image-gallery-card-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy class names for backward compatibility */
|
||||||
|
.groups-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.image-order { position: absolute; top: 10px; left: 10px; background: rgba(0, 0, 0, 0.7); color: white; border-radius: 12px; padding: 4px 8px; font-size: 12px; font-weight: bold; z-index: 2; }
|
||||||
|
.file-meta { font-size: 12px; color: #6c757d; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.gallery-title { margin-bottom: 15px; font-family: 'Roboto', sans-serif; color: #333; font-size: 1.5rem; font-weight: 500; }
|
||||||
|
.empty-gallery { text-align: center; padding: 40px 20px; color: #6c757d; font-style: italic; }
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.groups-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.groups-grid { grid-template-columns: 1fr; }
|
||||||
|
.group-actions { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
|
|
||||||
const GroupCard = ({ group, onApprove, onViewImages, onDelete, isPending, showActions = true }) => {
|
|
||||||
let previewUrl = null;
|
|
||||||
if (group.previewImage) {
|
|
||||||
previewUrl = `/download/${group.previewImage.split('/').pop()}`;
|
|
||||||
} else if (group.images && group.images.length > 0 && group.images[0].filePath) {
|
|
||||||
// images may provide filePath already
|
|
||||||
previewUrl = group.images[0].filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.imageCount} 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.uploadDate).toLocaleDateString('de-DE')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="group-actions">
|
|
||||||
{showActions ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => onViewImages(group)}
|
|
||||||
>
|
|
||||||
✏️ Gruppe editieren
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isPending ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-success"
|
|
||||||
onClick={() => onApprove(group.groupId, true)}
|
|
||||||
>
|
|
||||||
✅ Freigeben
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="btn btn-warning"
|
|
||||||
onClick={() => onApprove(group.groupId, false)}
|
|
||||||
>
|
|
||||||
⏸️ Sperren
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={() => onDelete(group.groupId)}
|
|
||||||
>
|
|
||||||
🗑️ Löschen
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="view-button"
|
|
||||||
onClick={() => onViewImages(group)}
|
|
||||||
title="Anzeigen"
|
|
||||||
>
|
|
||||||
Anzeigen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
GroupCard.propTypes = {
|
|
||||||
group: PropTypes.object.isRequired,
|
|
||||||
onApprove: PropTypes.func.isRequired,
|
|
||||||
onViewImages: PropTypes.func.isRequired,
|
|
||||||
onDelete: PropTypes.func.isRequired,
|
|
||||||
isPending: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GroupCard;
|
|
||||||
72
frontend/src/Components/ComponentUtils/ImageGallery.js
Normal file
72
frontend/src/Components/ComponentUtils/ImageGallery.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImageGalleryCard from './ImageGalleryCard';
|
||||||
|
import './Css/ImageGallery.css';
|
||||||
|
|
||||||
|
const ImageGallery = ({
|
||||||
|
items,
|
||||||
|
onApprove,
|
||||||
|
onViewImages,
|
||||||
|
onDelete,
|
||||||
|
isPending,
|
||||||
|
showActions,
|
||||||
|
mode,
|
||||||
|
title,
|
||||||
|
emptyMessage = 'Keine Elemente vorhanden'
|
||||||
|
}) => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="image-gallery-empty">
|
||||||
|
<p>{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-gallery-container">
|
||||||
|
{title && (
|
||||||
|
<h2 className="image-gallery-title">{title}</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="image-gallery-grid">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.id || item.groupId || index} className="grid-item-stretch">
|
||||||
|
<ImageGalleryCard
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onViewImages={onViewImages}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isPending={isPending}
|
||||||
|
showActions={showActions}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ImageGallery.propTypes = {
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
onApprove: PropTypes.func,
|
||||||
|
onViewImages: PropTypes.func,
|
||||||
|
onDelete: PropTypes.func,
|
||||||
|
isPending: PropTypes.bool,
|
||||||
|
showActions: PropTypes.bool,
|
||||||
|
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
||||||
|
title: PropTypes.string,
|
||||||
|
emptyMessage: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
ImageGallery.defaultProps = {
|
||||||
|
onApprove: () => {},
|
||||||
|
onViewImages: () => {},
|
||||||
|
onDelete: () => {},
|
||||||
|
isPending: false,
|
||||||
|
showActions: true,
|
||||||
|
mode: 'group'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageGallery;
|
||||||
199
frontend/src/Components/ComponentUtils/ImageGalleryCard.js
Normal file
199
frontend/src/Components/ComponentUtils/ImageGalleryCard.js
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import './Css/ImageGallery.css';
|
||||||
|
|
||||||
|
const ImageGalleryCard = ({
|
||||||
|
item,
|
||||||
|
onApprove,
|
||||||
|
onViewImages,
|
||||||
|
onDelete,
|
||||||
|
isPending,
|
||||||
|
showActions = true,
|
||||||
|
index,
|
||||||
|
mode = 'group', // 'group', 'moderation', or 'preview'
|
||||||
|
hidePreview = false // Hide the preview image section
|
||||||
|
}) => {
|
||||||
|
// Handle both group data and individual image preview data
|
||||||
|
let previewUrl = null;
|
||||||
|
let title = '';
|
||||||
|
let subtitle = '';
|
||||||
|
let description = '';
|
||||||
|
let uploadDate = '';
|
||||||
|
let imageCount = 0;
|
||||||
|
let itemId = '';
|
||||||
|
|
||||||
|
if (mode === 'preview' || mode === 'single-image') {
|
||||||
|
// Preview mode: display individual images
|
||||||
|
if (item.remoteUrl) {
|
||||||
|
previewUrl = item.remoteUrl;
|
||||||
|
} else if (item.url) {
|
||||||
|
previewUrl = item.url;
|
||||||
|
} else if (item.filePath) {
|
||||||
|
previewUrl = item.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
title = item.originalName || item.name || 'Bild';
|
||||||
|
|
||||||
|
// Show capture date if available
|
||||||
|
if (item.captureDate) {
|
||||||
|
subtitle = `Aufnahme: ${new Date(item.captureDate).toLocaleDateString('de-DE')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemId = item.id;
|
||||||
|
} else {
|
||||||
|
// Group mode: display group information
|
||||||
|
const group = item;
|
||||||
|
|
||||||
|
if (group.previewImage) {
|
||||||
|
previewUrl = `/download/${group.previewImage.split('/').pop()}`;
|
||||||
|
} else if (group.images && group.images.length > 0 && group.images[0].filePath) {
|
||||||
|
previewUrl = group.images[0].filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
title = group.title;
|
||||||
|
subtitle = `${group.year} • ${group.name}`;
|
||||||
|
description = group.description;
|
||||||
|
uploadDate = group.uploadDate;
|
||||||
|
imageCount = group.imageCount;
|
||||||
|
itemId = group.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`image-gallery-card ${isPending ? 'pending' : 'approved'} card-stretch`}>
|
||||||
|
{!hidePreview && (
|
||||||
|
<div className="image-gallery-card-preview">
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt="Preview" className="image-gallery-card-preview-image" />
|
||||||
|
) : (
|
||||||
|
<div className="image-gallery-card-no-preview">Kein Vorschaubild</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'preview' && index !== undefined && (
|
||||||
|
<div className="image-gallery-card-image-order">{index + 1}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode !== 'preview' && imageCount > 0 && (
|
||||||
|
<div className="image-gallery-card-image-count">{imageCount} Bilder</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="image-gallery-card-info">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{subtitle && <p className="image-gallery-card-meta">{subtitle}</p>}
|
||||||
|
{description && (
|
||||||
|
<p className="image-gallery-card-description">{description}</p>
|
||||||
|
)}
|
||||||
|
{uploadDate && (
|
||||||
|
<p className="image-gallery-card-upload-date">
|
||||||
|
Hochgeladen: {new Date(uploadDate).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional metadata for preview mode */}
|
||||||
|
{mode === 'preview' && item.remoteUrl && item.remoteUrl.includes('/download/') && (
|
||||||
|
<div className="image-gallery-card-file-meta">
|
||||||
|
Server-Datei: {item.remoteUrl.split('/').pop()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'preview' && item.filePath && !item.remoteUrl && (
|
||||||
|
<div className="image-gallery-card-file-meta">
|
||||||
|
Server-Datei: {item.filePath.split('/').pop()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only show actions section if there are actions to display */}
|
||||||
|
{(showActions || (mode !== 'single-image' && !showActions)) && (
|
||||||
|
<div className="image-gallery-card-actions">
|
||||||
|
{showActions ? (
|
||||||
|
mode === 'preview' ? (
|
||||||
|
// Preview mode actions (for upload preview)
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => onDelete(index !== undefined ? index : itemId)}
|
||||||
|
>
|
||||||
|
🗑️ Löschen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Sort
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Moderation mode actions (for existing groups)
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onViewImages(item)}
|
||||||
|
>
|
||||||
|
✏️ Gruppe editieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={() => onApprove(itemId, true)}
|
||||||
|
>
|
||||||
|
✅ Freigeben
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => onApprove(itemId, false)}
|
||||||
|
>
|
||||||
|
⏸️ Sperren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => onDelete(itemId)}
|
||||||
|
>
|
||||||
|
🗑️ Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : mode !== 'single-image' ? (
|
||||||
|
// Public view mode (only for group cards, not single images)
|
||||||
|
<button
|
||||||
|
className="view-button"
|
||||||
|
onClick={() => onViewImages(item)}
|
||||||
|
title="Anzeigen"
|
||||||
|
>
|
||||||
|
Anzeigen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ImageGalleryCard.propTypes = {
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
onApprove: PropTypes.func,
|
||||||
|
onViewImages: PropTypes.func,
|
||||||
|
onDelete: PropTypes.func,
|
||||||
|
isPending: PropTypes.bool,
|
||||||
|
showActions: PropTypes.bool,
|
||||||
|
index: PropTypes.number,
|
||||||
|
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
||||||
|
hidePreview: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
ImageGalleryCard.defaultProps = {
|
||||||
|
onApprove: () => {},
|
||||||
|
onViewImages: () => {},
|
||||||
|
onDelete: () => {},
|
||||||
|
isPending: false,
|
||||||
|
showActions: true,
|
||||||
|
mode: 'group',
|
||||||
|
hidePreview: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageGalleryCard;
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { makeStyles } from '@material-ui/core/styles';
|
|
||||||
import { Grid, Card, Typography } from '@material-ui/core';
|
|
||||||
|
|
||||||
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'
|
|
||||||
},
|
|
||||||
imageOrder: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '10px',
|
|
||||||
left: '10px',
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
||||||
color: 'white',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
zIndex: 2
|
|
||||||
},
|
|
||||||
fileMeta: {
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#6c757d',
|
|
||||||
marginTop: '6px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis'
|
|
||||||
},
|
|
||||||
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} alignItems="stretch">
|
|
||||||
{images.map((image, index) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index} className="grid-item-stretch">
|
|
||||||
<Card className={`group-card ${classes.imageCard} card-stretch`}>
|
|
||||||
<div className="group-preview">
|
|
||||||
<img
|
|
||||||
className="preview-image"
|
|
||||||
src={image && image.remoteUrl ? image.remoteUrl : image && image.url ? image.url : (image && image.filePath ? image.filePath : '')}
|
|
||||||
alt={`Vorschau ${index + 1}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ position: 'absolute', top: 10, left: 10 }} className={classes.imageOrder}>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="group-info">
|
|
||||||
<h3 style={{ fontSize: '0.95rem', margin: 0 }}>{image.originalName || image.name || 'Bild'}</h3>
|
|
||||||
<div className={classes.fileMeta}>
|
|
||||||
{image.remoteUrl && image.remoteUrl.includes('/download/') ? (
|
|
||||||
<div>Server-Datei: {image.remoteUrl.split('/').pop()}</div>
|
|
||||||
) : image.filePath ? (
|
|
||||||
<div>Server-Datei: {image.filePath.split('/').pop()}</div>
|
|
||||||
) : null}
|
|
||||||
{image.captureDate ? <div>Aufnahmedatum: {new Date(image.captureDate).toLocaleDateString('de-DE')}</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="group-actions">
|
|
||||||
<button className="btn btn-danger" onClick={() => handleRemoveImage(index)}>🗑️ Löschen</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled>Sort</button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImagePreviewGallery;
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
import { makeStyles } from '@material-ui/core/styles';
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
|
|
@ -41,46 +40,80 @@ const useStyles = makeStyles({
|
||||||
color: '#4CAF50',
|
color: '#4CAF50',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginTop: '10px'
|
marginTop: '10px'
|
||||||
|
},
|
||||||
|
hiddenInput: {
|
||||||
|
display: 'none'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles) => {
|
const handleFiles = (files) => {
|
||||||
// Filter nur Bilddateien
|
// Filter nur Bilddateien
|
||||||
const imageFiles = acceptedFiles.filter(file =>
|
const imageFiles = Array.from(files).filter(file =>
|
||||||
file.type.startsWith('image/')
|
file.type.startsWith('image/')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageFiles.length !== acceptedFiles.length) {
|
if (imageFiles.length !== files.length) {
|
||||||
alert('Nur Bilddateien sind erlaubt!');
|
alert('Nur Bilddateien sind erlaubt!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
console.log('Selected images:', imageFiles);
|
||||||
onImagesSelected(imageFiles);
|
onImagesSelected(imageFiles);
|
||||||
}, [onImagesSelected]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const handleDragOver = (e) => {
|
||||||
onDrop,
|
e.preventDefault();
|
||||||
accept: {
|
e.stopPropagation();
|
||||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.bmp', '.webp']
|
};
|
||||||
},
|
|
||||||
multiple: true,
|
const handleDragEnter = (e) => {
|
||||||
maxSize: 10 * 1024 * 1024 // 10MB pro Datei
|
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 (
|
return (
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
className={classes.dropzone}
|
||||||
className={`${classes.dropzone} ${isDragActive ? classes.dropzoneActive : ''}`}
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
|
||||||
|
|
||||||
<div className={classes.dropzoneText}>
|
<div className={classes.dropzoneText}>
|
||||||
{isDragActive ?
|
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
|
||||||
'Bilder hierher ziehen...' :
|
|
||||||
'Mehrere Bilder hier hinziehen oder klicken zum Auswählen'
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.dropzoneSubtext}>
|
<div className={classes.dropzoneSubtext}>
|
||||||
|
|
@ -89,10 +122,20 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
||||||
|
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
<div className={classes.fileCount}>
|
<div className={classes.fileCount}>
|
||||||
📸 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt
|
✅ {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="multi-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className={classes.hiddenInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
// Components
|
// Components
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import GroupCard from '../ComponentUtils/GroupCard';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { fetchAllGroups } from '../../Utils/batchUpload';
|
import { fetchAllGroups } from '../../Utils/batchUpload';
|
||||||
|
|
@ -53,6 +53,10 @@ function GroupsOverviewPage() {
|
||||||
history.push(`/slideshow/${groupId}`);
|
history.push(`/slideshow/${groupId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewGroup = (groupId) => {
|
||||||
|
history.push(`/groups/${groupId}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
history.push('/multi-upload');
|
history.push('/multi-upload');
|
||||||
};
|
};
|
||||||
|
|
@ -143,19 +147,13 @@ function GroupsOverviewPage() {
|
||||||
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<div className="groups-grid">
|
<ImageGallery
|
||||||
{groups.map((group) => (
|
items={groups}
|
||||||
<GroupCard
|
onViewImages={(group) => handleViewGroup(group.groupId)}
|
||||||
key={group.groupId}
|
|
||||||
group={group}
|
|
||||||
onApprove={() => { /* no-op on public page */ }}
|
|
||||||
onViewImages={() => handleViewSlideshow(group.groupId)}
|
|
||||||
onDelete={() => { /* no-op on public page */ }}
|
|
||||||
isPending={false}
|
isPending={false}
|
||||||
showActions={false}
|
showActions={false}
|
||||||
|
mode="group"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'sweetalert2/src/sweetalert2.scss';
|
||||||
// Components
|
// Components
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
|
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -132,7 +132,12 @@ const ModerationGroupImagesPage = () => {
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<Container maxWidth="lg" className="page-container">
|
<Container maxWidth="lg" className="page-container">
|
||||||
<ImagePreviewGallery images={selectedImages} onRemoveImage={handleRemoveImage} />
|
<ImageGallery
|
||||||
|
items={selectedImages}
|
||||||
|
onDelete={handleRemoveImage}
|
||||||
|
mode="preview"
|
||||||
|
showActions={true}
|
||||||
|
/>
|
||||||
|
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
|
||||||
import { Container } from '@material-ui/core';
|
import { Container } from '@material-ui/core';
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import GroupCard from '../ComponentUtils/GroupCard';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
|
|
||||||
const ModerationGroupsPage = () => {
|
const ModerationGroupsPage = () => {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
|
@ -157,23 +157,9 @@ const ModerationGroupsPage = () => {
|
||||||
<meta name="googlebot" content="noindex, nofollow" />
|
<meta name="googlebot" content="noindex, nofollow" />
|
||||||
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
|
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Container className="moderation-content">
|
|
||||||
<h1>Moderation</h1>
|
|
||||||
|
|
||||||
<div className="moderation-stats">
|
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
|
||||||
<div className="stat-item">
|
<h1>Moderation</h1>
|
||||||
<span className="stat-number">{pendingGroups.length}</span>
|
|
||||||
<span className="stat-label">Wartend</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number">{approvedGroups.length}</span>
|
|
||||||
<span className="stat-label">Freigegeben</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number">{groups.length}</span>
|
|
||||||
<span className="stat-label">Gesamt</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="moderation-stats">
|
<div className="moderation-stats">
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
|
|
@ -192,44 +178,30 @@ const ModerationGroupsPage = () => {
|
||||||
|
|
||||||
{/* Wartende Gruppen */}
|
{/* Wartende Gruppen */}
|
||||||
<section className="moderation-section">
|
<section className="moderation-section">
|
||||||
<h2>🔍 Wartende Freigabe ({pendingGroups.length})</h2>
|
<ImageGallery
|
||||||
{pendingGroups.length === 0 ? (
|
items={pendingGroups}
|
||||||
<p className="no-groups">Keine wartenden Gruppen</p>
|
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
|
||||||
) : (
|
|
||||||
<div className="groups-grid">
|
|
||||||
{pendingGroups.map(group => (
|
|
||||||
<GroupCard
|
|
||||||
key={group.groupId}
|
|
||||||
group={group}
|
|
||||||
onApprove={approveGroup}
|
onApprove={approveGroup}
|
||||||
onViewImages={viewGroupImages}
|
onViewImages={viewGroupImages}
|
||||||
onDelete={deleteGroup}
|
onDelete={deleteGroup}
|
||||||
isPending={true}
|
isPending={true}
|
||||||
|
mode="moderation"
|
||||||
|
emptyMessage="Keine wartenden Gruppen"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Freigegebene Gruppen */}
|
{/* Freigegebene Gruppen */}
|
||||||
<section className="moderation-section">
|
<section className="moderation-section">
|
||||||
<h2>✅ Freigegebene Gruppen ({approvedGroups.length})</h2>
|
<ImageGallery
|
||||||
{approvedGroups.length === 0 ? (
|
items={approvedGroups}
|
||||||
<p className="no-groups">Keine freigegebenen Gruppen</p>
|
title={`✅ Freigegebene Gruppen (${approvedGroups.length})`}
|
||||||
) : (
|
|
||||||
<div className="groups-grid">
|
|
||||||
{approvedGroups.map(group => (
|
|
||||||
<GroupCard
|
|
||||||
key={group.groupId}
|
|
||||||
group={group}
|
|
||||||
onApprove={approveGroup}
|
onApprove={approveGroup}
|
||||||
onViewImages={viewGroupImages}
|
onViewImages={viewGroupImages}
|
||||||
onDelete={deleteGroup}
|
onDelete={deleteGroup}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
|
mode="moderation"
|
||||||
|
emptyMessage="Keine freigegebenen Gruppen"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Bilder-Modal */}
|
{/* Bilder-Modal */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { makeStyles } from '@material-ui/core/styles';
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
import { Button, Card, CardContent, Typography, Container, Box } from '@material-ui/core';
|
import { Button, Card, CardContent, Typography, Container, Box } from '@material-ui/core';
|
||||||
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
||||||
|
|
@ -7,8 +7,8 @@ import 'sweetalert2/src/sweetalert2.scss';
|
||||||
// Components
|
// Components
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import MultiImageDropzone from '../ComponentUtils/MultiUpload/SimpleMultiImageDropzone';
|
import MultiImageDropzone from '../ComponentUtils/MultiUpload/MultiImageDropzone';
|
||||||
import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
|
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
|
||||||
import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress';
|
import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress';
|
||||||
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
||||||
|
|
@ -104,22 +104,56 @@ function MultiUploadPage() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
|
// Cleanup object URLs when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
selectedImages.forEach(img => {
|
||||||
|
if (img.url && img.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(img.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [selectedImages]);
|
||||||
|
|
||||||
const handleImagesSelected = (newImages) => {
|
const handleImagesSelected = (newImages) => {
|
||||||
console.log('handleImagesSelected called with:', newImages);
|
console.log('handleImagesSelected called with:', newImages);
|
||||||
|
|
||||||
|
// Convert File objects to preview objects with URLs
|
||||||
|
const imageObjects = newImages.map(file => ({
|
||||||
|
file: file, // Original File object for upload
|
||||||
|
url: URL.createObjectURL(file), // Preview URL
|
||||||
|
name: file.name,
|
||||||
|
originalName: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
}));
|
||||||
|
|
||||||
setSelectedImages(prev => {
|
setSelectedImages(prev => {
|
||||||
const updated = [...prev, ...newImages];
|
const updated = [...prev, ...imageObjects];
|
||||||
console.log('Updated selected images:', updated);
|
console.log('Updated selected images:', updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveImage = (indexToRemove) => {
|
const handleRemoveImage = (indexToRemove) => {
|
||||||
setSelectedImages(prev =>
|
setSelectedImages(prev => {
|
||||||
prev.filter((_, index) => index !== indexToRemove)
|
const imageToRemove = prev[indexToRemove];
|
||||||
);
|
// Clean up the object URL to avoid memory leaks
|
||||||
|
if (imageToRemove && imageToRemove.url && imageToRemove.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(imageToRemove.url);
|
||||||
|
}
|
||||||
|
return prev.filter((_, index) => index !== indexToRemove);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = () => {
|
const handleClearAll = () => {
|
||||||
|
// Clean up all object URLs
|
||||||
|
selectedImages.forEach(img => {
|
||||||
|
if (img.url && img.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(img.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setMetadata({
|
setMetadata({
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
|
|
@ -165,7 +199,9 @@ function MultiUploadPage() {
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
const result = await uploadImageBatch(selectedImages, metadata);
|
// Extract the actual File objects from our image objects
|
||||||
|
const filesToUpload = selectedImages.map(img => img.file || img);
|
||||||
|
const result = await uploadImageBatch(filesToUpload, metadata);
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
|
@ -213,7 +249,7 @@ function MultiUploadPage() {
|
||||||
Project Image Uploader
|
Project Image Uploader
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.subheaderText}>
|
<Typography className={classes.subheaderText}>
|
||||||
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe es in wenigen Worten.
|
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe dein Projekt in wenigen Worten.
|
||||||
<br />
|
<br />
|
||||||
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
|
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -225,9 +261,11 @@ function MultiUploadPage() {
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImagePreviewGallery
|
<ImageGallery
|
||||||
images={selectedImages}
|
items={selectedImages}
|
||||||
onRemoveImage={handleRemoveImage}
|
onDelete={handleRemoveImage}
|
||||||
|
mode="preview"
|
||||||
|
showActions={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useParams, useHistory } from 'react-router-dom';
|
||||||
import { Button, Container } from '@material-ui/core';
|
import { Button, Container } from '@material-ui/core';
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import GroupCard from '../ComponentUtils/GroupCard';
|
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
|
||||||
import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
|
|
||||||
|
|
||||||
const PublicGroupImagesPage = () => {
|
const PublicGroupImagesPage = () => {
|
||||||
|
|
@ -42,16 +42,25 @@ const PublicGroupImagesPage = () => {
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<Container maxWidth="lg" className="page-container">
|
<Container maxWidth="lg" className="page-container" style={{ marginTop: '40px' }}>
|
||||||
<GroupCard group={group} showActions={false} isPending={!group.approved} />
|
<ImageGalleryCard
|
||||||
|
item={group}
|
||||||
|
showActions={false}
|
||||||
|
isPending={!group.approved}
|
||||||
|
mode="group"
|
||||||
|
hidePreview={true}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="public-gallery">
|
<ImageGallery
|
||||||
{group.images && group.images.length > 0 ? (
|
items={group.images && group.images.length > 0 ? group.images.map(img => ({
|
||||||
<ImagePreviewGallery images={group.images.map(img => ({ remoteUrl: `/download/${img.fileName}`, originalName: img.originalName || img.fileName, id: img.id }))} showRemove={false} />
|
remoteUrl: `/download/${img.fileName}`,
|
||||||
) : (
|
originalName: img.originalName || img.fileName,
|
||||||
<p>Keine Bilder in dieser Gruppe.</p>
|
id: img.id
|
||||||
)}
|
})) : []}
|
||||||
</div>
|
showActions={false}
|
||||||
|
mode="single-image"
|
||||||
|
emptyMessage="Keine Bilder in dieser Gruppe."
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<div className="footerContainer"><Footer /></div>
|
<div className="footerContainer"><Footer /></div>
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
||||||
// Background.css is now globally imported in src/index.js
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
118
frontend/start-dev.sh
Normal file
118
frontend/start-dev.sh
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Make public writable so env.sh can write env-config.js
|
||||||
|
chmod -R a+rw ./public || true
|
||||||
|
|
||||||
|
# Run env.sh if present
|
||||||
|
if [ -x ./env.sh ]; then
|
||||||
|
./env.sh || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy nginx config from mounted source if available so dev config can be
|
||||||
|
# edited on the host without rebuilding the image. Priority:
|
||||||
|
# 1) ./conf/conf.d/default.conf (project's conf folder)
|
||||||
|
# 2) ./nginx.dev.conf (bundled with the dev image at build time)
|
||||||
|
if [ -f /app/conf/conf.d/default.conf ]; then
|
||||||
|
echo "Using nginx config from /app/conf/conf.d/default.conf (creating dev variant)"
|
||||||
|
# Backup original
|
||||||
|
cp /app/conf/conf.d/default.conf /app/conf/conf.d/default.conf.backup || true
|
||||||
|
# Write a deterministic dev nginx config that proxies known API routes to
|
||||||
|
# the backend and proxies '/' to the CRA dev server so HMR works through nginx.
|
||||||
|
cat > /etc/nginx/conf.d/default.conf <<'NGINXDEV'
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
client_max_body_size 200M;
|
||||||
|
|
||||||
|
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;
|
||||||
|
client_max_body_size 200M;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
client_max_body_size 200M;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /moderation/groups {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy webpack dev server (supports HMR / WebSocket upgrades)
|
||||||
|
location /sockjs-node/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
NGINXDEV
|
||||||
|
elif [ -f /app/nginx.dev.conf ]; then
|
||||||
|
echo "Using bundled nginx.dev.conf"
|
||||||
|
cp /app/nginx.dev.conf /etc/nginx/conf.d/default.conf || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure node cache exists and is writable
|
||||||
|
mkdir -p /app/node_modules/.cache || true
|
||||||
|
chmod -R a+rw /app/node_modules || true
|
||||||
|
|
||||||
|
# Ensure HOST is set so CRA binds to 0.0.0.0
|
||||||
|
export HOST=${HOST:-0.0.0.0}
|
||||||
|
|
||||||
|
# Start the React development server in background
|
||||||
|
npm run dev &
|
||||||
|
DEV_PID=$!
|
||||||
|
|
||||||
|
# Start nginx in foreground so container stays alive; nginx will proxy to the dev server
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
30
prod.sh
30
prod.sh
|
|
@ -43,12 +43,12 @@ read -p "Deine Wahl (0-13): " choice
|
||||||
case $choice in
|
case $choice in
|
||||||
1)
|
1)
|
||||||
echo -e "${GREEN}Starte Production Container...${NC}"
|
echo -e "${GREEN}Starte Production Container...${NC}"
|
||||||
docker compose up -d
|
docker compose -f docker-compose.yml up -d
|
||||||
echo -e "${GREEN}Container gestartet!${NC}"
|
echo -e "${GREEN}Container gestartet!${NC}"
|
||||||
echo -e "${BLUE}Frontend: http://localhost${NC}"
|
echo -e "${BLUE}Frontend: http://localhost${NC}"
|
||||||
echo -e "${BLUE}Backend: http://localhost:5000${NC}"
|
echo -e "${BLUE}Backend: http://localhost:5000${NC}"
|
||||||
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"
|
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"
|
||||||
echo -e "${BLUE}Logs verfolgen mit: docker compose logs -f${NC}"
|
echo -e "${BLUE}Logs verfolgen mit: docker compose -f docker-compose.yml logs -f${NC}"
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
echo -e "${GREEN}Baue Production Images...${NC}"
|
echo -e "${GREEN}Baue Production Images...${NC}"
|
||||||
|
|
@ -68,59 +68,59 @@ case $choice in
|
||||||
;;
|
;;
|
||||||
4)
|
4)
|
||||||
echo -e "${GREEN}Baue Container neu...${NC}"
|
echo -e "${GREEN}Baue Container neu...${NC}"
|
||||||
docker compose down
|
docker compose -f docker-compose.yml down
|
||||||
docker compose up --build -d
|
docker compose -f docker-compose.yml up --build -d
|
||||||
echo -e "${GREEN}Container neu gebaut und gestartet!${NC}"
|
echo -e "${GREEN}Container neu gebaut und gestartet!${NC}"
|
||||||
echo -e "${BLUE}Frontend: http://localhost${NC}"
|
echo -e "${BLUE}Frontend: http://localhost${NC}"
|
||||||
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"
|
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"
|
||||||
;;
|
;;
|
||||||
5)
|
5)
|
||||||
echo -e "${YELLOW}Stoppe Container...${NC}"
|
echo -e "${YELLOW}Stoppe Container...${NC}"
|
||||||
docker compose down
|
docker compose -f docker-compose.yml down
|
||||||
echo -e "${GREEN}Container gestoppt!${NC}"
|
echo -e "${GREEN}Container gestoppt!${NC}"
|
||||||
;;
|
;;
|
||||||
6)
|
6)
|
||||||
echo -e "${GREEN}Öffne Frontend Container Shell...${NC}"
|
echo -e "${GREEN}Öffne Frontend Container Shell...${NC}"
|
||||||
docker compose exec image-uploader-frontend bash
|
docker compose -f docker-compose.yml exec image-uploader-frontend bash
|
||||||
;;
|
;;
|
||||||
7)
|
7)
|
||||||
echo -e "${GREEN}Öffne Backend Container Shell...${NC}"
|
echo -e "${GREEN}Öffne Backend Container Shell...${NC}"
|
||||||
docker compose exec image-uploader-backend bash
|
docker compose -f docker-compose.yml exec image-uploader-backend bash
|
||||||
;;
|
;;
|
||||||
8)
|
8)
|
||||||
echo -e "${GREEN}Zeige Frontend Logs...${NC}"
|
echo -e "${GREEN}Zeige Frontend Logs...${NC}"
|
||||||
docker compose logs -f image-uploader-frontend
|
docker compose -f docker-compose.yml logs -f image-uploader-frontend
|
||||||
;;
|
;;
|
||||||
9)
|
9)
|
||||||
echo -e "${GREEN}Zeige Backend Logs...${NC}"
|
echo -e "${GREEN}Zeige Backend Logs...${NC}"
|
||||||
docker compose logs -f image-uploader-backend
|
docker compose -f docker-compose.yml logs -f image-uploader-backend
|
||||||
;;
|
;;
|
||||||
10)
|
10)
|
||||||
echo -e "${GREEN}Zeige alle Logs...${NC}"
|
echo -e "${GREEN}Zeige alle Logs...${NC}"
|
||||||
docker compose logs -f
|
docker compose -f docker-compose.yml logs -f
|
||||||
;;
|
;;
|
||||||
11)
|
11)
|
||||||
echo -e "${GREEN}Container Status:${NC}"
|
echo -e "${GREEN}Container Status:${NC}"
|
||||||
docker compose ps
|
docker compose -f docker-compose.yml ps
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Detaillierte Informationen:${NC}"
|
echo -e "${BLUE}Detaillierte Informationen:${NC}"
|
||||||
docker images | grep "image-uploader" || echo "Keine lokalen Images gefunden"
|
docker images | grep "image-uploader" || echo "Keine lokalen Images gefunden"
|
||||||
;;
|
;;
|
||||||
12)
|
12)
|
||||||
echo -e "${GREEN}Upload-Verzeichnis Inhalt:${NC}"
|
echo -e "${GREEN}Upload-Verzeichnis Inhalt:${NC}"
|
||||||
if docker compose ps -q image-uploader-backend > /dev/null 2>&1; then
|
if docker compose -f docker-compose.yml ps -q image-uploader-backend > /dev/null 2>&1; then
|
||||||
echo -e "${BLUE}Hochgeladene Bilder (data/images):${NC}"
|
echo -e "${BLUE}Hochgeladene Bilder (data/images):${NC}"
|
||||||
docker compose exec image-uploader-backend ls -la /usr/src/app/data/images/ || echo "Upload-Verzeichnis ist leer"
|
docker compose -f docker-compose.yml exec image-uploader-backend ls -la /usr/src/app/data/images/ || echo "Upload-Verzeichnis ist leer"
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}JSON Metadaten (data/db):${NC}"
|
echo -e "${BLUE}JSON Metadaten (data/db):${NC}"
|
||||||
docker compose exec image-uploader-backend ls -la /usr/src/app/data/db/ || echo "Keine Metadaten vorhanden"
|
docker compose -f docker-compose.yml exec image-uploader-backend ls -la /usr/src/app/data/db/ || echo "Keine Metadaten vorhanden"
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}Backend Container ist nicht gestartet.${NC}"
|
echo -e "${YELLOW}Backend Container ist nicht gestartet.${NC}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
13)
|
13)
|
||||||
echo -e "${YELLOW}Stoppe und lösche Container inkl. Volumes...${NC}"
|
echo -e "${YELLOW}Stoppe und lösche Container inkl. Volumes...${NC}"
|
||||||
docker compose down -v
|
docker compose -f docker-compose.yml down -v
|
||||||
echo -e "${GREEN}Container und Volumes gelöscht!${NC}"
|
echo -e "${GREEN}Container und Volumes gelöscht!${NC}"
|
||||||
;;
|
;;
|
||||||
0)
|
0)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user