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:
Matthias Lotz 2025-10-27 22:22:52 +01:00
parent 237c776ddc
commit a0d74f795a
44 changed files with 1318 additions and 1513 deletions

52
README.dev.md Normal file
View File

@ -0,0 +1,52 @@
## Dev: Schnellstart
Kurz und knapp — so startest und nutzt du die lokale DevUmgebung 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 nginxKonfiguration anpassen willst, editiere `frontend/conf/conf.d/default.conf` (DevVariante 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 hostseitiges `frontend/node_modules` hast, lösche es (konsistenter ist der containerverwaltete 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 DevKonfiguration läuft lokal mit erweiterten Rechten (nur für Entwicklung). ProduktionsImages/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

174
README.md
View File

@ -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
│ ├── ZMmHXzHbqw.jpg └── app
│ ├── tjjnngOmXS.jpg ├── src
│ └── ...### Slideshow JSON Format ├── upload
└── data/ # Metadata │ ├── ZMmHXzHbqw.jpg
└── upload-groups.json │ ├── tjjnngOmXS.jpg
│ └── ...
└── data
└── 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
View File

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

View File

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

View 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

View File

@ -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
View 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"]

View File

@ -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 cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

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

39
frontend/nginx.dev.conf Normal file
View 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;
}
}

View File

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

View File

@ -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; }

View File

@ -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} />

View File

@ -1 +0,0 @@
#TODO: move GroudCars styles into this file

View 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%;
}
}

View File

@ -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%;
}
}
*/

View 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; }
}

View File

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

View 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;

View 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;

View File

@ -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>
);
}

View File

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

View File

@ -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>
); );
} }

View File

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

View File

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

View File

@ -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>
)
}
}

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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 })}>
&times;
</span>
<img className="modal-content" id="img01" src={this.state.modalSrc} alt="Uploaded"/>
</div>
</div>
</Fragment>
);
}
}

View File

@ -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;
}

View File

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

View File

@ -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 && (
<> <>

View File

@ -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 */}

View File

@ -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 && (

View File

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

View File

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

View File

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

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