Compare commits

..

14 Commits

Author SHA1 Message Date
Gitea Actions Bot f7a8765f58 chore: bump version to 1.0.108 (backend + frontend) 2025-09-02 15:46:57 +00:00
paul 214f120f7a chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-02 17:40:23 +02:00
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
paul 67ff415840 fix: resolve feedback validation issues from GitHub issue #16
- Fixed backend validation to properly handle empty strings in validateGuestRequirements
- Added Boolean conversion for SQLite boolean values in feedback settings API response
- Created FeedbackIdentityModal component for collecting name/email when required
- Updated PhotoLikes, PhotoRating, and PhotoFavorites components to show modal when requireNameEmail is true
- Fixed issue where require_name_email field was not reaching frontend due to missing boolean conversion

This ensures that when 'Require Name & Email' is enabled, guests are prompted with a modal to provide their information before submitting feedback, preventing 400 Bad Request errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
Gitea Actions Bot 88659f1fa6 chore: bump frontend version to 1.0.107 2025-09-02 14:14:52 +00:00
paul c1e10f14a3 fix: resolve translation interpolation issue for download button
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 1m13s
Version and Release / trigger-drone (push) Successful in 3s
Fixed the download selected button not displaying count properly.
The translation key 'gallery.downloadSelected' was not receiving
the count parameter for interpolation, causing "{{count}}" to
display literally instead of the actual number.

Fixes the issue where the button showed:
- "Download {{count}} Selected" instead of "Download 2 Selected"
- "{{count}} ausgewählte herunterladen" instead of "3 ausgewählte herunterladen"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:08:14 +02:00
Gitea Actions Bot 0881a0fa71 chore: bump version to 1.0.106 (backend + frontend) 2025-09-01 21:03:36 +00:00
paul e91209f7cb fix: resolve multiple issues from GitHub issue #14
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m54s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed duplicate German translation for 'downloadSelected' button
- Added client_max_body_size configuration in nginx for file uploads
- Fixed date parsing in FeedbackModerationPanel to handle timestamps
- Fixed admin authentication context (req.admin vs req.user) in feedback routes
- Enhanced clipboard functionality with fallback for non-HTTPS contexts
- Fixed authentication token handling for numeric event IDs in uploads

These changes ensure comment moderation works properly, file uploads are configured correctly, and the UI handles all edge cases properly.

Fixes #14

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 22:56:44 +02:00
paul 828d6bc456 fix: correct script name in Gitea mirror workflow
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Has been skipped
- Fix script name from gitea-runner.sh to install-gitea-runner.sh
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:37:12 +02:00
paul f945573f09 chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Has started running
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-08-29 22:33:38 +02:00
paul 296430e4d7 fix: update Gitea mirror workflow to selectively remove scripts
- Only remove gitea-runner.sh instead of entire scripts directory
- Preserve useful deployment and utility scripts in GitHub mirror
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:33:38 +02:00
Gitea Actions Bot 7b517fa290 chore: bump version to 1.0.105 (backend + frontend) 2025-08-29 20:28:39 +00:00
paul 2c9a56f217 docs: update deployment guide with GitHub Container Registry images
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Added instructions for using pre-built images from ghcr.io
- Created docker-compose.production.yml for quick deployment with official images
- Updated deployment guide with two methods:
  1. Using pre-built images (fastest, recommended)
  2. Building from source (for customization)
- Updated SIMPLE_SETUP references to use new unified script
- Added specific version deployment instructions
- Maintained backward compatibility with local build process

The pre-built images eliminate build time and ensure consistent deployments
across environments. Users can now deploy PicPeak in minutes using:
- ghcr.io/the-luap/picpeak/backend:latest
- ghcr.io/the-luap/picpeak/frontend:latest

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:22:40 +02:00
paul 986b101040 fix: remove unnecessary publish-manifest job from Docker workflow
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Has been skipped
The publish-manifest job was failing because it tried to create manifests
from non-existent architecture-specific tags (latest-amd64, latest-arm64).

docker/build-push-action@v5 already creates multi-arch manifests automatically
when building for multiple platforms, making this job redundant.

The workflow now correctly builds and pushes multi-arch images in a single
step with proper manifest lists included.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:16:35 +02:00
26 changed files with 1248 additions and 418 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .gitea/ || true
rm -rf scripts/ || true
rm -rf scripts/install-gitea-runner.sh || true
rm -rf .drone* || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
+3 -31
View File
@@ -179,37 +179,9 @@ jobs:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
publish-manifest:
needs: [build-backend, build-frontend]
if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest for Backend
run: |
docker manifest create \
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest \
--amend ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest-amd64 \
--amend ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest-arm64
docker manifest push ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest
- name: Create and push multi-arch manifest for Frontend
run: |
docker manifest create \
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest \
--amend ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest-amd64 \
--amend ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest-arm64
docker manifest push ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
# automatically creates multi-arch manifests when building for multiple platforms.
# The images are already properly tagged and include all architectures.
summary:
needs: [build-backend, build-frontend]
+131 -10
View File
@@ -4,27 +4,62 @@ This guide covers multiple deployment options for PicPeak, from simple local set
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
For the easiest installation without Docker or complex configurations, use our **simple setup script**:
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
```bash
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/simple-setup.sh -o setup.sh && \
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
```
This automated script handles everything including OS detection, dependencies, database setup, and service configuration. Perfect for:
This automated script handles everything including:
- Choice between Docker or Native installation
- OS detection and dependency installation
- Database setup and service configuration
- SSL/HTTPS setup (optional)
Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users who prefer avoiding Docker complexity
- Users new to server management
- Quick testing and evaluation
👉 **See [SIMPLE_SETUP_GUIDE.md](./SIMPLE_SETUP_GUIDE.md) for detailed instructions.**
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
This section covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
docker compose -f docker-compose.production.yml up -d
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
### Option 2: Building from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
## 📋 Table of Contents
@@ -46,6 +81,38 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
## 🚀 Quick Start
### Method 1: Using Pre-built Images (Fastest)
1. **Clone the repository for configs**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Deploy using pre-built images**
```bash
docker compose -f docker-compose.production.yml up -d
```
5. **Check logs**
```bash
docker compose -f docker-compose.production.yml logs -f
```
### Method 2: Building from Source
1. **Clone the repository**
```bash
git clone https://github.com/the-luap/picpeak.git
@@ -64,8 +131,9 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
chmod -R 755 events data logs backup storage
```
4. **Deploy**
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
@@ -145,12 +213,29 @@ SMTP_PASS=your-sendgrid-api-key
## 📦 Deployment
### Build and Start Services
### Using Pre-built Images (Fastest)
```bash
# Build images
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
@@ -446,14 +531,50 @@ The application includes a built-in backup service. Configure it in the admin pa
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version Updates
To use a specific version of the images:
```bash
# Edit docker-compose.production.yml to specify version tags
# Change: ghcr.io/the-luap/picpeak/backend:latest
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Database Migrations
Binary file not shown.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.104",
"version": "1.0.108",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.104",
"version": "1.0.108",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.104",
"version": "1.0.108",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+4 -4
View File
@@ -60,8 +60,8 @@ router.put('/events/:eventId/feedback-settings',
settings: updatedSettings
}, eventId, {
type: 'admin',
id: req.user.id,
name: req.user.username
id: req.admin.id,
name: req.admin.username
});
res.json(updatedSettings);
@@ -167,7 +167,7 @@ router.put('/feedback/:feedbackId/:action',
return res.status(400).json({ error: 'Invalid action' });
}
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
res.json({ success: true });
} catch (error) {
@@ -184,7 +184,7 @@ router.delete('/feedback/:feedbackId',
try {
const { feedbackId } = req.params;
await feedbackService.deleteFeedback(feedbackId, req.user.id);
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
res.json({ success: true });
} catch (error) {
+8 -7
View File
@@ -24,14 +24,15 @@ router.get('/:slug/feedback-settings',
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
// Convert SQLite boolean values (0/1) to proper booleans
const guestSettings = {
feedback_enabled: settings.feedback_enabled,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
require_name_email: settings.require_name_email,
show_feedback_to_guests: settings.show_feedback_to_guests
feedback_enabled: Boolean(settings.feedback_enabled),
allow_ratings: Boolean(settings.allow_ratings),
allow_likes: Boolean(settings.allow_likes),
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
};
res.json(guestSettings);
+8 -2
View File
@@ -227,11 +227,17 @@ async function validateGuestRequirements(settings, guestData) {
const errors = [];
if (!guestData.guest_name || guestData.guest_name.trim().length === 0) {
// Check for name - handle both undefined and empty strings
const name = guestData.guest_name;
if (!name || (typeof name === 'string' && name.trim().length === 0)) {
errors.push('Name is required');
}
if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) {
// Check for email - handle both undefined and empty strings
const email = guestData.guest_email;
if (!email || (typeof email === 'string' && email.trim().length === 0)) {
errors.push('Email is required');
} else if (email && typeof email === 'string' && !validator.isEmail(email.trim())) {
errors.push('Valid email is required');
}
+113
View File
@@ -0,0 +1,113 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: picpeak-postgres
environment:
POSTGRES_USER: ${DB_USER:-picpeak}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-picpeak}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: picpeak-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/backend:latest
container_name: picpeak-backend
env_file: .env
environment:
- NODE_ENV=production
- DB_HOST=postgres
- REDIS_HOST=redis
- PHOTOS_DIR=/app/storage/events
volumes:
- ./storage:/app/storage
- ./logs:/app/logs
- ./data:/app/data
ports:
- "${BACKEND_PORT:-3001}:3000"
networks:
- picpeak-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/frontend:latest
container_name: picpeak-frontend
environment:
- VITE_API_URL=${FRONTEND_API_URL:-http://localhost:3001}
ports:
- "${FRONTEND_PORT:-3000}:80"
networks:
- picpeak-network
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Nginx reverse proxy for production with SSL
# Uncomment and configure if you want built-in HTTPS support
# nginx:
# image: nginx:alpine
# container_name: picpeak-nginx
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# - ./nginx/ssl:/etc/nginx/ssl:ro
# - ./nginx/conf.d:/etc/nginx/conf.d:ro
# networks:
# - picpeak-network
# depends_on:
# - frontend
# - backend
# restart: unless-stopped
volumes:
postgres-data:
driver: local
redis-data:
driver: local
networks:
picpeak-network:
driver: bridge
+8
View File
@@ -4,6 +4,10 @@ server {
root /usr/share/nginx/html;
index index.html;
# Allow larger file uploads (up to 100MB)
client_max_body_size 100M;
client_body_timeout 300s;
# Gzip compression
gzip on;
gzip_vary on;
@@ -49,6 +53,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
# Allow larger uploads for API endpoints
client_max_body_size 100M;
client_body_timeout 300s;
}
# Photo serving proxy
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.104",
"version": "1.0.108",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.104",
"version": "1.0.108",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.104",
"version": "1.0.108",
"type": "module",
"scripts": {
"dev": "vite",
@@ -118,14 +118,30 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</span>
<span className="text-neutral-500"></span>
<span className="text-neutral-500">
{format(parseISO(item.created_at), 'MMM d, h:mm a')}
{format(
typeof item.created_at === 'string'
? parseISO(item.created_at)
: new Date(item.created_at),
'MMM d, h:mm a'
)}
</span>
</div>
<p className="mt-1 text-sm text-neutral-700">{item.comment}</p>
{item.photo_filename && (
<p className="mt-1 text-xs text-neutral-500">
{t('feedback.onPhoto', 'On photo')}: {item.photo_filename}
</p>
<p className="mt-1 text-sm text-neutral-700">{item.comment_text || item.comment}</p>
{item.photo_id && (
<div className="mt-2 flex items-center gap-2">
<img
src={`/api/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
alt={item.filename || 'Photo'}
className="w-16 h-16 object-cover rounded"
onError={(e) => {
// Hide image if thumbnail fails to load
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<p className="text-xs text-neutral-500">
{t('feedback.onPhoto', 'On photo')}: {item.filename || item.photo_filename || `#${item.photo_id}`}
</p>
</div>
)}
</div>
</div>
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
interface FeedbackIdentityModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (name: string, email: string) => void;
feedbackType: string;
}
export const FeedbackIdentityModal: React.FC<FeedbackIdentityModalProps> = ({
isOpen,
onClose,
onSubmit,
feedbackType
}) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = t('feedback.nameRequired', 'Name is required');
}
if (!email.trim()) {
newErrors.email = t('feedback.emailRequired', 'Email is required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('feedback.invalidEmail', 'Invalid email address');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(name.trim(), email.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-neutral-600" />
</button>
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
{t('feedback.identityRequired', 'Your Information Required')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('feedback.identityReason', 'Please provide your name and email to submit {{type}}.', { type: feedbackType })}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('feedback.yourName', 'Your Name')}
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
placeholder={t('feedback.namePlaceholder', 'Enter your name')}
required
/>
<Input
type="email"
label={t('feedback.yourEmail', 'Your Email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
placeholder={t('feedback.emailPlaceholder', 'Enter your email')}
required
/>
<div className="flex gap-2 pt-2">
<Button
type="submit"
variant="primary"
className="flex-1"
>
{t('feedback.submitFeedback', 'Submit Feedback')}
</Button>
<Button
type="button"
variant="ghost"
onClick={onClose}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</form>
</div>
</div>
);
};
@@ -194,7 +194,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
disabled={isDownloading}
className="w-full"
>
{t('gallery.downloadSelected')} ({selectedCount})
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
</Button>
)}
</div>
@@ -103,8 +103,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
submitCommentMutation.mutate({
comment_text: commentText.trim(),
guest_name: guestName.trim(),
guest_email: guestEmail.trim()
guest_name: guestName.trim() || undefined,
guest_email: guestEmail.trim() || undefined
});
};
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
interface PhotoFavoritesProps {
photoId: string;
@@ -11,6 +12,7 @@ interface PhotoFavoritesProps {
isFavorited: boolean;
favoriteCount: number;
isEnabled: boolean;
requireNameEmail?: boolean;
onFavoriteChange?: (favorited: boolean) => void;
}
@@ -20,17 +22,22 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
isFavorited,
favoriteCount,
isEnabled,
requireNameEmail = false,
onFavoriteChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const submitFavoriteMutation = useMutation({
mutationFn: () =>
mutationFn: (data: { guest_name?: string; guest_email?: string } = {}) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'favorite'
feedback_type: 'favorite',
guest_name: data.guest_name || undefined,
guest_email: data.guest_email || undefined
}),
onMutate: async () => {
setIsSubmitting(true);
@@ -62,13 +69,25 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
const handleFavoriteClick = () => {
if (!isEnabled || isSubmitting) return;
submitFavoriteMutation.mutate();
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
submitFavoriteMutation.mutate(savedIdentity || {});
}
};
const handleIdentitySubmit = (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
submitFavoriteMutation.mutate({ guest_name: name, guest_email: email });
};
if (!isEnabled) return null;
return (
<button
<>
<button
onClick={handleFavoriteClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
@@ -88,6 +107,13 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
<span className="text-sm font-medium">
{favoriteCount > 0 ? favoriteCount : ''}
</span>
</button>
</button>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => setShowIdentityModal(false)}
onSubmit={handleIdentitySubmit}
feedbackType={t('feedback.favorite', 'favorite')}
/>
</>
);
};
@@ -26,7 +26,11 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
// Fetch feedback settings for the gallery
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['gallery-feedback-settings', gallerySlug],
queryFn: () => feedbackService.getGalleryFeedbackSettings(gallerySlug),
queryFn: async () => {
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
console.log('PhotoFeedback received settings:', data);
return data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
@@ -104,6 +108,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
averageRating={Number(feedbackData?.summary?.average_rating) || 0}
totalRatings={Number(feedbackData?.summary?.total_ratings) || 0}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onRatingChange={handleRatingChange}
/>
)}
@@ -118,6 +123,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
isLiked={isLiked}
likeCount={likeCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onLikeChange={handleLikeChange}
/>
)}
@@ -128,6 +134,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onFavoriteChange={handleFavoriteChange}
/>
)}
+31 -5
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
interface PhotoLikesProps {
photoId: string;
@@ -11,6 +12,7 @@ interface PhotoLikesProps {
isLiked: boolean;
likeCount: number;
isEnabled: boolean;
requireNameEmail?: boolean;
onLikeChange?: (liked: boolean) => void;
}
@@ -20,17 +22,22 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
isLiked,
likeCount,
isEnabled,
requireNameEmail = false,
onLikeChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const submitLikeMutation = useMutation({
mutationFn: () =>
mutationFn: (data: { guest_name?: string; guest_email?: string } = {}) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'like'
feedback_type: 'like',
guest_name: data.guest_name || undefined,
guest_email: data.guest_email || undefined
}),
onMutate: async () => {
setIsSubmitting(true);
@@ -62,13 +69,25 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
const handleLikeClick = () => {
if (!isEnabled || isSubmitting) return;
submitLikeMutation.mutate();
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
submitLikeMutation.mutate(savedIdentity || {});
}
};
const handleIdentitySubmit = (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
submitLikeMutation.mutate({ guest_name: name, guest_email: email });
};
if (!isEnabled) return null;
return (
<button
<>
<button
onClick={handleLikeClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
@@ -88,6 +107,13 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
<span className="text-sm font-medium">
{likeCount > 0 ? likeCount : ''}
</span>
</button>
</button>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => setShowIdentityModal(false)}
onSubmit={handleIdentitySubmit}
feedbackType={t('feedback.like', 'like')}
/>
</>
);
};
+75 -39
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
interface PhotoRatingProps {
photoId: string;
@@ -12,6 +13,7 @@ interface PhotoRatingProps {
averageRating?: number;
totalRatings?: number;
isEnabled: boolean;
requireNameEmail?: boolean;
onRatingChange?: (rating: number) => void;
}
@@ -22,6 +24,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
averageRating = 0,
totalRatings = 0,
isEnabled,
requireNameEmail = false,
onRatingChange
}) => {
// Ensure averageRating is a valid number
@@ -30,18 +33,23 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
const queryClient = useQueryClient();
const [hoveredRating, setHoveredRating] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingRating, setPendingRating] = useState(0);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const submitRatingMutation = useMutation({
mutationFn: (rating: number) =>
mutationFn: (data: { rating: number; guest_name?: string; guest_email?: string }) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'rating',
rating
rating: data.rating,
guest_name: data.guest_name || undefined,
guest_email: data.guest_email || undefined
}),
onMutate: async (rating) => {
onMutate: async (data) => {
setIsSubmitting(true);
// Optimistic update
if (onRatingChange) {
onRatingChange(rating);
onRatingChange(data.rating);
}
},
onSuccess: () => {
@@ -69,47 +77,75 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
// If clicking the same rating, remove it
const newRating = rating === currentRating ? 0 : rating;
submitRatingMutation.mutate(newRating);
if (requireNameEmail && !savedIdentity) {
setPendingRating(newRating);
setShowIdentityModal(true);
} else {
submitRatingMutation.mutate({
rating: newRating,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email
});
}
};
const handleIdentitySubmit = (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
submitRatingMutation.mutate({
rating: pendingRating,
guest_name: name,
guest_email: email
});
};
if (!isEnabled) return null;
return (
<div className="flex flex-col items-center gap-2">
{/* Star Rating Input */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => handleRatingClick(star)}
onMouseEnter={() => setHoveredRating(star)}
onMouseLeave={() => setHoveredRating(0)}
disabled={isSubmitting}
className={`p-1 transition-all ${
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
}`}
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
>
<Star
className={`w-6 h-6 transition-colors ${
star <= (hoveredRating || currentRating)
? 'fill-yellow-500 text-yellow-500'
: 'text-neutral-300 hover:text-yellow-400'
<>
<div className="flex flex-col items-center gap-2">
{/* Star Rating Input */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => handleRatingClick(star)}
onMouseEnter={() => setHoveredRating(star)}
onMouseLeave={() => setHoveredRating(0)}
disabled={isSubmitting}
className={`p-1 transition-all ${
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
}`}
/>
</button>
))}
</div>
{/* Average Rating Display */}
{totalRatings > 0 && (
<div className="text-sm text-neutral-600">
<span className="font-medium">{safeAverageRating.toFixed(1)}</span>
<span className="text-neutral-400 ml-1">
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
</span>
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
>
<Star
className={`w-6 h-6 transition-colors ${
star <= (hoveredRating || currentRating)
? 'fill-yellow-500 text-yellow-500'
: 'text-neutral-300 hover:text-yellow-400'
}`}
/>
</button>
))}
</div>
)}
</div>
{/* Average Rating Display */}
{totalRatings > 0 && (
<div className="text-sm text-neutral-600">
<span className="font-medium">{safeAverageRating.toFixed(1)}</span>
<span className="text-neutral-400 ml-1">
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
</span>
</div>
)}
</div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => setShowIdentityModal(false)}
onSubmit={handleIdentitySubmit}
feedbackType={t('feedback.rating', 'rating')}
/>
</>
);
};
+18 -3
View File
@@ -48,10 +48,25 @@ api.interceptors.request.use(
const galleryMatch = config.url?.match(/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
const galleryIdOrSlug = galleryMatch[1];
// Remove any query parameters from the slug
const cleanSlug = gallerySlug.split('?')[0];
const token = localStorage.getItem(`gallery_token_${cleanSlug}`);
const cleanIdOrSlug = galleryIdOrSlug.split('?')[0];
// Check if it's a numeric ID (for upload endpoints)
let token = null;
if (/^\d+$/.test(cleanIdOrSlug)) {
// It's an event ID - try to find the token from current page slug
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const cleanSlug = gallerySlug.split('?')[0];
token = localStorage.getItem(`gallery_token_${cleanSlug}`);
}
} else {
// It's a slug - use it directly
token = localStorage.getItem(`gallery_token_${cleanIdOrSlug}`);
}
if (token) {
if (!config.headers) {
config.headers = {};
-1
View File
@@ -134,7 +134,6 @@
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"allPhotos": "Alle Fotos",
"downloadSelected": "Ausgewählte herunterladen",
"shareGallery": "Galerie teilen",
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
"noPhotosFound": "Keine Fotos gefunden",
+28 -2
View File
@@ -279,12 +279,38 @@ export const EventDetailsPage: React.FC = () => {
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(event.share_link);
// Check if share_link exists
if (!event.share_link) {
toast.error(t('errors.noShareLink', 'No share link available'));
return;
}
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(event.share_link);
} else {
// Fallback for non-HTTPS contexts or older browsers
const textArea = document.createElement('textarea');
textArea.value = event.share_link;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Copy failed');
}
}
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
toast.success(t('toast.linkCopied'));
} catch (err) {
toast.error(t('errors.somethingWentWrong'));
console.error('Copy failed:', err);
toast.error(t('errors.copyFailed', 'Failed to copy link. Please copy manually.'));
}
};
+11 -5
View File
@@ -250,11 +250,17 @@ export const EventFeedbackPage: React.FC = () => {
{feedbackData?.feedback?.map((item: PhotoFeedback) => (
<Card key={item.id} className="overflow-hidden">
<div className="p-4 flex items-start gap-4">
<img
src={`/thumbnails/${item.path}`}
alt={item.filename}
className="w-16 h-16 object-cover rounded"
/>
{item.photo_id && (
<img
src={`/api/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
alt={item.filename || 'Photo'}
className="w-16 h-16 object-cover rounded"
onError={(e) => {
// Hide image if thumbnail fails to load
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1">
<div className="flex items-start justify-between">
<div>