diff --git a/README.dev.md b/README.dev.md new file mode 100644 index 0000000..79f5ad3 --- /dev/null +++ b/README.dev.md @@ -0,0 +1,52 @@ +## Dev: Schnellstart + +Kurz und knapp — so startest und nutzt du die lokale Dev‑Umgebung mit HMR (nginx als Proxy vor dem CRA dev server): + +Voraussetzungen +- Docker & Docker Compose (Docker Compose Plugin) + +Starten (Dev) +1. Build & Start (daemon): +```bash +docker compose up --build -d image-uploader-frontend +``` +2. Logs verfolgen: +```bash +docker compose logs -f image-uploader-frontend +``` +3. Browser öffnen: http://localhost:3000 (HMR aktiv) + +Ändern & Testen +- Dateien editieren im `frontend/src/...` → HMR übernimmt Änderungen sofort. +- Wenn du nginx‑Konfiguration anpassen willst, editiere `frontend/conf/conf.d/default.conf` (Dev‑Variante wird beim Containerstart benutzt). Nach Änderung: nginx reload ohne Neustart: +```bash +docker compose exec image-uploader-frontend nginx -s reload +``` + +Probleme mit `node_modules` +- Wenn du ein host‑seitiges `frontend/node_modules` hast, lösche es (konsistenter ist der container‑verwaltete Volume): +```bash +rm -rf frontend/node_modules +``` +Danach `docker compose up --build -d image-uploader-frontend` erneut ausführen. + +Stoppen +```bash +docker compose down +``` + +Hinweis +- Diese Dev‑Konfiguration läuft lokal mit erweiterten Rechten (nur für Entwicklung). Produktions‑Images/Configs bleiben unverändert. + + +Build and start: +docker compose up --build -d image-uploader-frontend + +Tail logs: +docker compose logs -f image-uploader-frontend + +Reload nginx (after editing conf in container): +docker compose exec image-uploader-frontend nginx -s reload + +docker compose exec image-uploader-frontend nginx -s reload +docker compose down \ No newline at end of file diff --git a/README.md b/README.md index 6b993ba..9ffb6c2 100644 --- a/README.md +++ b/README.md @@ -29,41 +29,45 @@ This project extends the original [Image-Uploader by vallezw](https://github.com ```yaml services: - frontend: - image: vallezw/image-uploader-client - ports: - - "80:80" - depends_on: - - backend - environment: - - "API_URL=http://localhost:5000" - - "CLIENT_URL=http://localhost" - container_name: frontend - backend: - image: vallezw/image-uploader-backend - environment: - - "CLIENT_URL=http://localhost" - container_name: backend - backend: - image: vallezw/image-uploader-client - ports: - - "80:80" - container_name: frontend - image: vallezw/image-uploader-backend - ports: - - "5000:5000" - container_name: backend - volumes: - - app-data:/usr/src/app/src/upload - depends_on: - - app-data:/usr/src/app/src/data - - backend + image-uploader-frontend: + image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-frontend:latest + ports: + - "80:80" + build: + context: ./frontend + dockerfile: ./Dockerfile + depends_on: + - "image-uploader-backend" + environment: + - "API_URL=http://image-uploader-backend:5000" + - "CLIENT_URL=http://localhost" + container_name: "image-uploader-frontend" + networks: + - npm-nw + - image-uploader-internal + + image-uploader-backend: + image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-backend:latest + ports: + - "5000:5000" + build: + context: ./backend + dockerfile: ./Dockerfile + container_name: "image-uploader-backend" + networks: + - image-uploader-internal + volumes: + - app-data:/usr/src/app/src/data + volumes: - app-data: - environment: - - "API_URL=http://localhost:5000" - - "CLIENT_URL=http://localhost" - driver: local + app-data: + driver: local + +networks: + npm-nw: + external: true + image-uploader-internal: + driver: bridge ``` 2. **Start the application**: @@ -113,68 +117,66 @@ docker compose up -d - View group details (title, creator, description, image count) - 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` - Overview of all approved slideshow collections - Launch slideshow mode from any group - View group statistics and metadata - -**Security Features**: -- Password protected access via nginx HTTP Basic Auth -- Hidden from search engines (`robots.txt` + `noindex` meta tags) -- No public links or references in main interface + ## Data Structure -### Slideshow JSON Format -```json -[ - { - "groupId": "0fSwazTOU", - "description": "My Photo Collection", - "uploadDate": "2025-10-11T14:34:48.159Z", - "images": - { - "fileName": "ZMmHXzHbqw.jpg", - "originalName": "vacation-photo-1.jpg", - "filePath": "/upload/ZMmHXzHbqw.jpg", - "uploadOrder": 1 - }, - { - "fileName": "tjjnngOmXS.jpg", - "originalName": "vacation-photo-2.jpg", - "filePath": "/upload/tjjnngOmXS.jpg", - "uploadOrder": 2 - } - ], - "imageCount": 21 - } -] +Data are stored in sqlite database. The structure is as follows: +``` sql +CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT UNIQUE NOT NULL, + year INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + name TEXT, + upload_date DATETIME NOT NULL, + approved BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL, + file_name TEXT NOT NULL, + original_name TEXT NOT NULL, + file_path TEXT NOT NULL, + upload_order INTEGER NOT NULL, + file_size INTEGER, + mime_type TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE + ); +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 ### Backend (Node.js + Express) - **Multi-upload API**: `/api/upload/batch` - Handles batch file processing - **Groups API**: `/api/groups` - Retrieves slideshow collections - **File Storage**: Organized in `/upload` directory -- **Metadata Storage**: JSON files in `/data` directory +- **Database Storage**: sqlite database in `/app/src/data/db/image_uploader.db` ### Frontend (React + Material-UI) @@ -190,12 +192,17 @@ docker compose up -d ``` Docker Volume (app-data) -├── upload/ -│ ├── ZMmHXzHbqw.jpg -│ ├── tjjnngOmXS.jpg -│ └── ...### Slideshow JSON Format -└── data/ # Metadata - └── upload-groups.json +src +└── app + ├── src + ├── upload + │ ├── ZMmHXzHbqw.jpg + │ ├── tjjnngOmXS.jpg + │ └── ... + └── data + └── db + └── image_uploader.db + ``` ### Hosting it with Docker @@ -233,24 +240,9 @@ Docker Volume (app-data) | `CLIENT_URL` | `http://localhost` | Frontend application URL | ### Volume Configuration - -- **Data Persistence**: `/usr/src/app/src/upload` and `/usr/src/app/src/data` mounted to `app-data` - **Upload Limits**: 100MB maximum file size for batch uploads - **Supported Formats**: JPG, JPEG, PNG, GIF, WebP -### Custom Deployment -For production deployment, modify the docker-compose configuration: - -```yaml - -environment: - - - "API_URL=https://your-domain.com/api" - - - "CLIENT_URL=https://your-domain.com" -``` - - ### Backup & Restore #### Backup slideshow data diff --git a/TODO.md b/TODO.md index bf14c39..9362d8d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,19 +2,19 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich pflege sie, sobald ich Aufgaben erledige. -## Aktuelle Aufgaben +## Anstehende Aufgaben -- [ ] CSS-Sweep: Duplikate finden und Regeln in `frontend/src/app.css` zentralisieren -- [ ] Page-CSS bereinigen: Entfernen von Regeln, die jetzt in `frontend/src/app.css` sind -- [ ] README: Kurzbeschreibung des Style-Guides und wo zentrale Klassen liegen +### Frontend + +- [ ] 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 -- [ ] 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 @@ -31,7 +31,6 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich p ## 🚀 Deployment-Überlegungen - ### Speicher-Management - **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 - **Virus-Scanning**: Optional für Produktionsumgebung - --- ## 📈 Erweiterungs-Möglichkeiten (Zukunft) @@ -54,7 +52,6 @@ Diese Datei listet die noch offenen Arbeiten, die ich im Projekt verfolge. Ich p --- - ## 🎯 Erfolgskriterien ### Must-Have diff --git a/backend/Dockerfile b/backend/Dockerfile index 245f4fc..b3dd04d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,6 +2,14 @@ FROM node:14 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 ./ # Development diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..24cacf5 --- /dev/null +++ b/docker-compose.override.yml @@ -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 \ No newline at end of file diff --git a/docs/images/example-video.gif b/docs/images/example-video.gif deleted file mode 100644 index 44f3dcf..0000000 Binary files a/docs/images/example-video.gif and /dev/null differ diff --git a/docs/images/logo.png b/docs/images/logo.png deleted file mode 100644 index 0b6d11e..0000000 Binary files a/docs/images/logo.png and /dev/null differ diff --git a/docs/images/screenshot.png b/docs/images/screenshot.png deleted file mode 100644 index 3b58bcb..0000000 Binary files a/docs/images/screenshot.png and /dev/null differ diff --git a/docs/images/vallezw-Image-Uploader-dark.png b/docs/images/vallezw-Image-Uploader-dark.png deleted file mode 100644 index fccff15..0000000 Binary files a/docs/images/vallezw-Image-Uploader-dark.png and /dev/null differ diff --git a/docs/images/vallezw-Image-Uploader-light.png b/docs/images/vallezw-Image-Uploader-light.png deleted file mode 100644 index dcea2db..0000000 Binary files a/docs/images/vallezw-Image-Uploader-light.png and /dev/null differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e8b1c87..d0b9c9a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # => Build container -FROM node:18-alpine as build +FROM node:18-alpine AS build WORKDIR /app COPY package.json ./ RUN npm install --silent diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..6137b73 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -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"] diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 0c83cde..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Getting Started with Create React App - -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). - -## Available Scripts - -In the project directory, you can run: - -### `npm start` - -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.\ -You will also see any lint errors in the console. - -### `npm test` - -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `npm run build` - -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `npm run eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). - -### Code Splitting - -This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) - -### Analyzing the Bundle Size - -This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) - -### Making a Progressive Web App - -This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) - -### Advanced Configuration - -This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) - -### Deployment - -This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) - -### `npm run build` fails to minify - -This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) diff --git a/frontend/conf/conf.d/default.conf.backup b/frontend/conf/conf.d/default.conf.backup new file mode 100644 index 0000000..c0ff033 --- /dev/null +++ b/frontend/conf/conf.d/default.conf.backup @@ -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; + } +} \ No newline at end of file diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf new file mode 100644 index 0000000..5bfdb2e --- /dev/null +++ b/frontend/nginx.dev.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0864d46..9d15ceb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "version": "0.1.0", "dependencies": { "@material-ui/core": "^4.11.3", @@ -17,6 +18,7 @@ "react-code-blocks": "^0.0.8", "react-dom": "^17.0.1", "react-dropzone": "^11.3.1", + "react-helmet": "^6.1.0", "react-lottie": "^1.2.3", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", @@ -15040,6 +15042,27 @@ "version": "6.0.9", "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": { "version": "17.0.1", "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": { "version": "12.2.1", "license": "MIT", @@ -18040,18 +18072,6 @@ "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": { "version": "1.4.4", "license": "MIT", @@ -21585,8 +21605,7 @@ } }, "@material-ui/types": { - "version": "5.1.0", - "requires": {} + "version": "5.1.0" }, "@material-ui/utils": { "version": "4.11.2", @@ -22328,8 +22347,7 @@ } }, "acorn-jsx": { - "version": "5.3.1", - "requires": {} + "version": "5.3.1" }, "acorn-walk": { "version": "7.2.0" @@ -22361,12 +22379,10 @@ } }, "ajv-errors": { - "version": "1.0.1", - "requires": {} + "version": "1.0.1" }, "ajv-keywords": { - "version": "3.5.2", - "requires": {} + "version": "3.5.2" }, "alphanum-sort": { "version": "1.0.2" @@ -22716,8 +22732,7 @@ } }, "babel-plugin-named-asset-import": { - "version": "0.3.7", - "requires": {} + "version": "0.3.7" }, "babel-plugin-polyfill-corejs2": { "version": "0.1.10", @@ -24828,8 +24843,7 @@ } }, "eslint-plugin-react-hooks": { - "version": "4.2.0", - "requires": {} + "version": "4.2.0" }, "eslint-plugin-testing-library": { "version": "3.10.1", @@ -27068,8 +27082,7 @@ } }, "jest-pnp-resolver": { - "version": "1.2.2", - "requires": {} + "version": "1.2.2" }, "jest-regex-util": { "version": "26.0.0" @@ -29978,6 +29991,22 @@ "react-error-overlay": { "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": { "version": "17.0.1" }, @@ -30096,6 +30125,11 @@ "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": { "version": "12.2.1", "requires": { @@ -31973,13 +32007,8 @@ "is-typedarray": "^1.0.0" } }, - "typescript": { - "version": "3.9.9", - "peer": true - }, "typescript-plugin-styled-components": { - "version": "1.4.4", - "requires": {} + "version": "1.4.4" }, "unbox-primitive": { "version": "1.0.0", @@ -33372,8 +33401,7 @@ } }, "ws": { - "version": "7.4.4", - "requires": {} + "version": "7.4.4" }, "xml-name-validator": { "version": "3.0.0" diff --git a/frontend/src/App.css b/frontend/src/App.css index ebec324..86faf8b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -8,8 +8,8 @@ @media (max-width:800px) { .nav__links, .cta { display:none; } } /* Page-specific styles for ModerationPage */ -.moderation-page { max-width: 1200px; margin: 0 auto; padding: 20px; } -.moderation-page h1 { text-align:center; color:#333; margin-bottom:30px; } +.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; } +.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-error { color:#dc3545; } @@ -18,6 +18,8 @@ .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; } + + .moderation-section { margin-bottom:50px; } .moderation-section h2 { color:#333; border-bottom:2px solid #e9ecef; padding-bottom:10px; margin-bottom:25px; } .no-groups { text-align:center; color:#6c757d; font-style:italic; padding:30px; } @@ -47,42 +49,6 @@ 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 */ .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; } @@ -113,15 +79,11 @@ @media (max-width:768px) { .moderation-stats { flex-direction:column; gap:20px; } - .groups-grid { grid-template-columns:1fr; } - .group-actions { flex-direction:column; } .btn { width:100%; } .image-modal { max-width:95vw; max-height:95vh; } .images-grid { grid-template-columns:repeat(auto-fit, minmax(150px,1fr)); } } -/* 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) */ .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; } diff --git a/frontend/src/App.js b/frontend/src/App.js index 42bed25..19f9237 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -2,7 +2,6 @@ import './App.css'; import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; // Pages -import UploadedImage from './Components/Pages/UploadedImagePage'; import MultiUploadPage from './Components/Pages/MultiUploadPage'; import SlideshowPage from './Components/Pages/SlideshowPage'; import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage'; @@ -16,7 +15,6 @@ function App() { - diff --git a/frontend/src/Components/ComponentUtils/Css/GroupCard.css b/frontend/src/Components/ComponentUtils/Css/GroupCard.css deleted file mode 100644 index e31d7c2..0000000 --- a/frontend/src/Components/ComponentUtils/Css/GroupCard.css +++ /dev/null @@ -1 +0,0 @@ -#TODO: move GroudCars styles into this file \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/Css/Image.css b/frontend/src/Components/ComponentUtils/Css/Image.css deleted file mode 100644 index 3950044..0000000 --- a/frontend/src/Components/ComponentUtils/Css/Image.css +++ /dev/null @@ -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%; - } - } \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/Css/Image.scss b/frontend/src/Components/ComponentUtils/Css/Image.scss deleted file mode 100644 index 5196a7a..0000000 --- a/frontend/src/Components/ComponentUtils/Css/Image.scss +++ /dev/null @@ -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%; - } - } - */ \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/Css/ImageGallery.css b/frontend/src/Components/ComponentUtils/Css/ImageGallery.css new file mode 100644 index 0000000..1f800f8 --- /dev/null +++ b/frontend/src/Components/ComponentUtils/Css/ImageGallery.css @@ -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; } +} diff --git a/frontend/src/Components/ComponentUtils/GroupCard.js b/frontend/src/Components/ComponentUtils/GroupCard.js deleted file mode 100644 index 3026615..0000000 --- a/frontend/src/Components/ComponentUtils/GroupCard.js +++ /dev/null @@ -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 ( -
-
- {previewUrl ? ( - Preview - ) : ( -
Kein Vorschaubild
- )} -
{group.imageCount} Bilder
-
- -
-

{group.title}

-

{group.year} • {group.name}

- {group.description && ( -

{group.description}

- )} -

- Hochgeladen: {new Date(group.uploadDate).toLocaleDateString('de-DE')} -

-
- -
- {showActions ? ( - <> - - - {isPending ? ( - - ) : ( - - )} - - - - ) : ( - - )} -
-
- ); -}; - -GroupCard.propTypes = { - group: PropTypes.object.isRequired, - onApprove: PropTypes.func.isRequired, - onViewImages: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, - isPending: PropTypes.bool -}; - -export default GroupCard; diff --git a/frontend/src/Components/ComponentUtils/ImageGallery.js b/frontend/src/Components/ComponentUtils/ImageGallery.js new file mode 100644 index 0000000..59951a5 --- /dev/null +++ b/frontend/src/Components/ComponentUtils/ImageGallery.js @@ -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 ( +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ {title && ( +

{title}

+ )} + +
+ {items.map((item, index) => ( +
+ +
+ ))} +
+
+ ); +}; + +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; diff --git a/frontend/src/Components/ComponentUtils/ImageGalleryCard.js b/frontend/src/Components/ComponentUtils/ImageGalleryCard.js new file mode 100644 index 0000000..74589c3 --- /dev/null +++ b/frontend/src/Components/ComponentUtils/ImageGalleryCard.js @@ -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 ( +
+ {!hidePreview && ( +
+ {previewUrl ? ( + Preview + ) : ( +
Kein Vorschaubild
+ )} + + {mode === 'preview' && index !== undefined && ( +
{index + 1}
+ )} + + {mode !== 'preview' && imageCount > 0 && ( +
{imageCount} Bilder
+ )} +
+ )} + +
+

{title}

+ {subtitle &&

{subtitle}

} + {description && ( +

{description}

+ )} + {uploadDate && ( +

+ Hochgeladen: {new Date(uploadDate).toLocaleDateString('de-DE')} +

+ )} + + {/* Additional metadata for preview mode */} + {mode === 'preview' && item.remoteUrl && item.remoteUrl.includes('/download/') && ( +
+ Server-Datei: {item.remoteUrl.split('/').pop()} +
+ )} + {mode === 'preview' && item.filePath && !item.remoteUrl && ( +
+ Server-Datei: {item.filePath.split('/').pop()} +
+ )} +
+ + {/* Only show actions section if there are actions to display */} + {(showActions || (mode !== 'single-image' && !showActions)) && ( +
+ {showActions ? ( + mode === 'preview' ? ( + // Preview mode actions (for upload preview) + <> + + + + ) : ( + // Moderation mode actions (for existing groups) + <> + + + {isPending ? ( + + ) : ( + + )} + + + + ) + ) : mode !== 'single-image' ? ( + // Public view mode (only for group cards, not single images) + + ) : null} +
+ )} +
+ ); +}; + +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; diff --git a/frontend/src/Components/ComponentUtils/ImageUploadCard.js b/frontend/src/Components/ComponentUtils/ImageUploadCard.js deleted file mode 100644 index aa66375..0000000 --- a/frontend/src/Components/ComponentUtils/ImageUploadCard.js +++ /dev/null @@ -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 ( -
- {!props.loading? -
- - - -

Upload your image

-

File should be Jpeg, Png, ...

- - -
-
-
-
- : -
- -
- } -
- ); -} - - - diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js b/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js deleted file mode 100644 index c3ea0a9..0000000 --- a/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js +++ /dev/null @@ -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 ( -
- - Vorschau ({images.length} Bild{images.length !== 1 ? 'er' : ''}) - - - - {images.map((image, index) => ( - - -
- {`Vorschau -
- -
- {index + 1} -
- -
-

{image.originalName || image.name || 'Bild'}

-
- {image.remoteUrl && image.remoteUrl.includes('/download/') ? ( -
Server-Datei: {image.remoteUrl.split('/').pop()}
- ) : image.filePath ? ( -
Server-Datei: {image.filePath.split('/').pop()}
- ) : null} - {image.captureDate ?
Aufnahmedatum: {new Date(image.captureDate).toLocaleDateString('de-DE')}
: null} -
-
- -
- - -
-
-
- ))} -
-
- ); -} - -export default ImagePreviewGallery; \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/MultiImageDropzone.js b/frontend/src/Components/ComponentUtils/MultiUpload/MultiImageDropzone.js index b533e98..a8cd039 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/MultiImageDropzone.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/MultiImageDropzone.js @@ -1,5 +1,4 @@ import React, { useCallback } from 'react'; -import { useDropzone } from 'react-dropzone'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles({ @@ -41,57 +40,101 @@ const useStyles = makeStyles({ color: '#4CAF50', fontWeight: 'bold', marginTop: '10px' + }, + hiddenInput: { + display: 'none' } }); function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) { const classes = useStyles(); - const onDrop = useCallback((acceptedFiles) => { + const handleFiles = (files) => { // Filter nur Bilddateien - const imageFiles = acceptedFiles.filter(file => + const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/') ); - if (imageFiles.length !== acceptedFiles.length) { + if (imageFiles.length !== files.length) { alert('Nur Bilddateien sind erlaubt!'); } - onImagesSelected(imageFiles); - }, [onImagesSelected]); + if (imageFiles.length > 0) { + console.log('Selected images:', imageFiles); + onImagesSelected(imageFiles); + } + }; - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.bmp', '.webp'] - }, - multiple: true, - maxSize: 10 * 1024 * 1024 // 10MB pro Datei - }); + 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 ( -
- - -
- {isDragActive ? - 'Bilder hierher ziehen...' : - 'Mehrere Bilder hier hinziehen oder klicken zum Auswählen' - } -
- -
- Unterstützte Formate: JPG, PNG, GIF, WebP (max. 10MB pro Datei) -
- - {selectedImages.length > 0 && ( -
- 📸 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt +
+
+
+ 📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
- )} + +
+ Unterstützte Formate: JPG, PNG, GIF, WebP (max. 10MB pro Datei) +
+ + {selectedImages.length > 0 && ( +
+ ✅ {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt +
+ )} +
+ +
); } diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/SimpleMultiImageDropzone.js b/frontend/src/Components/ComponentUtils/MultiUpload/SimpleMultiImageDropzone.js deleted file mode 100644 index a8cd039..0000000 --- a/frontend/src/Components/ComponentUtils/MultiUpload/SimpleMultiImageDropzone.js +++ /dev/null @@ -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 ( -
-
-
- 📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen -
- -
- Unterstützte Formate: JPG, PNG, GIF, WebP (max. 10MB pro Datei) -
- - {selectedImages.length > 0 && ( -
- ✅ {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} ausgewählt -
- )} -
- - -
- ); -} - -export default MultiImageDropzone; \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/SocialMedia/Css/SocialMedia.css b/frontend/src/Components/ComponentUtils/SocialMedia/Css/SocialMedia.css deleted file mode 100644 index af75a4c..0000000 --- a/frontend/src/Components/ComponentUtils/SocialMedia/Css/SocialMedia.css +++ /dev/null @@ -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 -} - diff --git a/frontend/src/Components/ComponentUtils/SocialMedia/SocialMediaShareButtons.js b/frontend/src/Components/ComponentUtils/SocialMedia/SocialMediaShareButtons.js deleted file mode 100644 index 7bb0a4e..0000000 --- a/frontend/src/Components/ComponentUtils/SocialMedia/SocialMediaShareButtons.js +++ /dev/null @@ -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 ( -
- Share -
- - - - - -
-
- ) - } -} - diff --git a/frontend/src/Components/ComponentUtils/StyledDropzone.js b/frontend/src/Components/ComponentUtils/StyledDropzone.js deleted file mode 100644 index b81f499..0000000 --- a/frontend/src/Components/ComponentUtils/StyledDropzone.js +++ /dev/null @@ -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 ( -
-
-

Drag 'n' drop your image here

-
- -
- goingUpImage -
-
- ); -} \ No newline at end of file diff --git a/frontend/src/Components/ComponentUtils/UploadButton.js b/frontend/src/Components/ComponentUtils/UploadButton.js deleted file mode 100644 index 5aa43d8..0000000 --- a/frontend/src/Components/ComponentUtils/UploadButton.js +++ /dev/null @@ -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( - - - - - - ) -} - diff --git a/frontend/src/Components/ComponentUtils/UploadedImage.js b/frontend/src/Components/ComponentUtils/UploadedImage.js deleted file mode 100644 index c06c4a6..0000000 --- a/frontend/src/Components/ComponentUtils/UploadedImage.js +++ /dev/null @@ -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 ( - - { - this.setState({ showModal: true, caption: "Uploaded", modalSrc: image_url}); - }} - alt="Uploaded" - onError={() => this.props.imageNotFound()} - /> - -
-
- this.setState({ showModal: false })}> - × - - Uploaded -
-
-
- ); - } - } \ No newline at end of file diff --git a/frontend/src/Components/Pages/Css/UploadedImagePage.css b/frontend/src/Components/Pages/Css/UploadedImagePage.css deleted file mode 100644 index dbd15e4..0000000 --- a/frontend/src/Components/Pages/Css/UploadedImagePage.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/frontend/src/Components/Pages/GroupsOverviewPage.js b/frontend/src/Components/Pages/GroupsOverviewPage.js index 8a34d91..0c4091d 100644 --- a/frontend/src/Components/Pages/GroupsOverviewPage.js +++ b/frontend/src/Components/Pages/GroupsOverviewPage.js @@ -15,7 +15,7 @@ import { // Components import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import GroupCard from '../ComponentUtils/GroupCard'; +import ImageGallery from '../ComponentUtils/ImageGallery'; // Utils import { fetchAllGroups } from '../../Utils/batchUpload'; @@ -53,6 +53,10 @@ function GroupsOverviewPage() { history.push(`/slideshow/${groupId}`); }; + const handleViewGroup = (groupId) => { + history.push(`/groups/${groupId}`); + }; + const handleCreateNew = () => { history.push('/multi-upload'); }; @@ -143,19 +147,13 @@ function GroupsOverviewPage() { 📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden -
- {groups.map((group) => ( - { /* no-op on public page */ }} - onViewImages={() => handleViewSlideshow(group.groupId)} - onDelete={() => { /* no-op on public page */ }} - isPending={false} - showActions={false} - /> - ))} -
+ handleViewGroup(group.groupId)} + isPending={false} + showActions={false} + mode="group" + /> )} diff --git a/frontend/src/Components/Pages/ModerationGroupImagesPage.js b/frontend/src/Components/Pages/ModerationGroupImagesPage.js index 67416d9..f5165b8 100644 --- a/frontend/src/Components/Pages/ModerationGroupImagesPage.js +++ b/frontend/src/Components/Pages/ModerationGroupImagesPage.js @@ -7,7 +7,7 @@ import 'sweetalert2/src/sweetalert2.scss'; // Components import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; +import ImageGallery from '../ComponentUtils/ImageGallery'; import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; @@ -132,7 +132,12 @@ const ModerationGroupImagesPage = () => { - + {selectedImages.length > 0 && ( <> diff --git a/frontend/src/Components/Pages/ModerationGroupsPage.js b/frontend/src/Components/Pages/ModerationGroupsPage.js index 719dbeb..f0ef83e 100644 --- a/frontend/src/Components/Pages/ModerationGroupsPage.js +++ b/frontend/src/Components/Pages/ModerationGroupsPage.js @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import { Container } from '@material-ui/core'; import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import GroupCard from '../ComponentUtils/GroupCard'; +import ImageGallery from '../ComponentUtils/ImageGallery'; const ModerationGroupsPage = () => { const [groups, setGroups] = useState([]); @@ -157,79 +157,51 @@ const ModerationGroupsPage = () => { - -

Moderation

- -
-
- {pendingGroups.length} - Wartend -
-
- {approvedGroups.length} - Freigegeben -
-
- {groups.length} - Gesamt -
-
-
-
- {pendingGroups.length} - Wartend + +

Moderation

+ +
+
+ {pendingGroups.length} + Wartend +
+
+ {approvedGroups.length} + Freigegeben +
+
+ {groups.length} + Gesamt +
-
- {approvedGroups.length} - Freigegeben -
-
- {groups.length} - Gesamt -
-
{/* Wartende Gruppen */}
-

🔍 Wartende Freigabe ({pendingGroups.length})

- {pendingGroups.length === 0 ? ( -

Keine wartenden Gruppen

- ) : ( -
- {pendingGroups.map(group => ( - - ))} -
- )} +
{/* Freigegebene Gruppen */}
-

✅ Freigegebene Gruppen ({approvedGroups.length})

- {approvedGroups.length === 0 ? ( -

Keine freigegebenen Gruppen

- ) : ( -
- {approvedGroups.map(group => ( - - ))} -
- )} +
{/* Bilder-Modal */} diff --git a/frontend/src/Components/Pages/MultiUploadPage.js b/frontend/src/Components/Pages/MultiUploadPage.js index 9101588..dac7f20 100644 --- a/frontend/src/Components/Pages/MultiUploadPage.js +++ b/frontend/src/Components/Pages/MultiUploadPage.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { Button, Card, CardContent, Typography, Container, Box } from '@material-ui/core'; import Swal from 'sweetalert2/dist/sweetalert2.js'; @@ -7,8 +7,8 @@ import 'sweetalert2/src/sweetalert2.scss'; // Components import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import MultiImageDropzone from '../ComponentUtils/MultiUpload/SimpleMultiImageDropzone'; -import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; +import MultiImageDropzone from '../ComponentUtils/MultiUpload/MultiImageDropzone'; +import ImageGallery from '../ComponentUtils/ImageGallery'; import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress'; import Loading from '../ComponentUtils/LoadingAnimation/Loading'; @@ -104,22 +104,56 @@ function MultiUploadPage() { const [uploading, setUploading] = useState(false); 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) => { 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 => { - const updated = [...prev, ...newImages]; + const updated = [...prev, ...imageObjects]; console.log('Updated selected images:', updated); return updated; }); }; const handleRemoveImage = (indexToRemove) => { - setSelectedImages(prev => - prev.filter((_, index) => index !== indexToRemove) - ); + setSelectedImages(prev => { + 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 = () => { + // Clean up all object URLs + selectedImages.forEach(img => { + if (img.url && img.url.startsWith('blob:')) { + URL.revokeObjectURL(img.url); + } + }); + setSelectedImages([]); setMetadata({ year: new Date().getFullYear(), @@ -165,7 +199,9 @@ function MultiUploadPage() { }); }, 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); setUploadProgress(100); @@ -213,7 +249,7 @@ function MultiUploadPage() { Project Image Uploader - 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.
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
@@ -225,9 +261,11 @@ function MultiUploadPage() { selectedImages={selectedImages} /> - {selectedImages.length > 0 && ( diff --git a/frontend/src/Components/Pages/PublicGroupImagesPage.js b/frontend/src/Components/Pages/PublicGroupImagesPage.js index 917df9a..121da90 100644 --- a/frontend/src/Components/Pages/PublicGroupImagesPage.js +++ b/frontend/src/Components/Pages/PublicGroupImagesPage.js @@ -3,8 +3,8 @@ import { useParams, useHistory } from 'react-router-dom'; import { Button, Container } from '@material-ui/core'; import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import GroupCard from '../ComponentUtils/GroupCard'; -import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; +import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard'; +import ImageGallery from '../ComponentUtils/ImageGallery'; const PublicGroupImagesPage = () => { @@ -42,16 +42,25 @@ const PublicGroupImagesPage = () => {
- - + + -
- {group.images && group.images.length > 0 ? ( - ({ remoteUrl: `/download/${img.fileName}`, originalName: img.originalName || img.fileName, id: img.id }))} showRemove={false} /> - ) : ( -

Keine Bilder in dieser Gruppe.

- )} -
+ 0 ? group.images.map(img => ({ + remoteUrl: `/download/${img.fileName}`, + originalName: img.originalName || img.fileName, + id: img.id + })) : []} + showActions={false} + mode="single-image" + emptyMessage="Keine Bilder in dieser Gruppe." + />
diff --git a/frontend/src/Components/Pages/UploadPage.js b/frontend/src/Components/Pages/UploadPage.js deleted file mode 100644 index c07681b..0000000 --- a/frontend/src/Components/Pages/UploadPage.js +++ /dev/null @@ -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 ( -
- - - - -
- -
-
- -
-
-
-
- ) -} - -export default UploadPage diff --git a/frontend/src/Components/Pages/UploadedImagePage.js b/frontend/src/Components/Pages/UploadedImagePage.js deleted file mode 100644 index 282af9e..0000000 --- a/frontend/src/Components/Pages/UploadedImagePage.js +++ /dev/null @@ -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 ( -
- {imageFound? -
- -
- setImageFound(false)}/> - -
-
-
- : - - } -
- ) -} - -export default UploadedImagePage diff --git a/frontend/start-dev.sh b/frontend/start-dev.sh new file mode 100644 index 0000000..2473adb --- /dev/null +++ b/frontend/start-dev.sh @@ -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;' diff --git a/prod.sh b/prod.sh index 4bb9b9b..96619b1 100755 --- a/prod.sh +++ b/prod.sh @@ -43,12 +43,12 @@ read -p "Deine Wahl (0-13): " choice case $choice in 1) 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 "${BLUE}Frontend: http://localhost${NC}" echo -e "${BLUE}Backend: http://localhost:5000${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) echo -e "${GREEN}Baue Production Images...${NC}" @@ -68,59 +68,59 @@ case $choice in ;; 4) echo -e "${GREEN}Baue Container neu...${NC}" - docker compose down - docker compose up --build -d + docker compose -f docker-compose.yml down + docker compose -f docker-compose.yml up --build -d echo -e "${GREEN}Container neu gebaut und gestartet!${NC}" echo -e "${BLUE}Frontend: http://localhost${NC}" echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}" ;; 5) echo -e "${YELLOW}Stoppe Container...${NC}" - docker compose down + docker compose -f docker-compose.yml down echo -e "${GREEN}Container gestoppt!${NC}" ;; 6) 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) 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) 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) 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) echo -e "${GREEN}Zeige alle Logs...${NC}" - docker compose logs -f + docker compose -f docker-compose.yml logs -f ;; 11) echo -e "${GREEN}Container Status:${NC}" - docker compose ps + docker compose -f docker-compose.yml ps echo echo -e "${BLUE}Detaillierte Informationen:${NC}" docker images | grep "image-uploader" || echo "Keine lokalen Images gefunden" ;; 12) 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}" - 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 -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 echo -e "${YELLOW}Backend Container ist nicht gestartet.${NC}" fi ;; 13) 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}" ;; 0)