Compare commits

..

13 Commits

Author SHA1 Message Date
Gitea Actions Bot 1d826accdc chore: bump version to 1.0.109 (backend + frontend) 2025-09-05 13:07:58 +00:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul e9171c7115 docs: follow-up on PR #15 — clarify VITE_API_URL usage, compose mounts, and admin routing (refs #15) 2025-09-05 15:01:16 +02:00
paul 758c085467 docs: clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs #18) 2025-09-05 15:01:16 +02:00
paul ecbc48815d docs(compose): fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs #18) 2025-09-05 15:01:16 +02:00
paul e91b138154 chore: remove unintended local artifacts and SQLite DB; update .gitignore (refs #18) 2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
paul 41857ec499 feat: implement feedback filter for liked/favorited photos (Issue #17)
Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
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
52 changed files with 2529 additions and 256 deletions
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -34,9 +34,15 @@ SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
# Application URLs
# Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin.
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com:3001
VITE_API_URL=https://yourdomain.com:3001/api
ADMIN_URL=https://yourdomain.com
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
VITE_API_URL=/api
# Port Configuration (optional)
# BACKEND_PORT=3001
@@ -50,4 +56,16 @@ TZ=UTC
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
VITE_UMAMI_SHARE_URL=
VITE_UMAMI_SHARE_URL=
# Storage variables (host paths)
# These control where data is stored on the host. Defaults are local folders.
APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
# should you change VITE_API_URL at build time.
+9
View File
@@ -72,3 +72,12 @@ logs/
storage/
data/
certbot/
# Ignore local contributor guide copy
AGENTS.md
# Local artifacts from browser tooling
.playwright-mcp/
# Local SQLite files in backend
backend/*.sqlite*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

+44 -20
View File
@@ -170,8 +170,28 @@ Update `.env` with:
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend URL (e.g., `http://localhost:3000` for Docker)
- `ADMIN_URL` - Admin URL (e.g., `http://localhost:3000` for Docker)
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
@@ -181,9 +201,10 @@ cp frontend/.env.example frontend/.env
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- For Docker deployment: `http://localhost:3001/api`
- For non-Docker local dev: `http://localhost:3001`
- For production with reverse proxy: `/api`
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
@@ -246,8 +267,8 @@ docker compose ps
### Access Points
By default, services are exposed on:
- Frontend: http://localhost:3000
- Backend/API: http://localhost:3001
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
@@ -318,7 +339,11 @@ After deployment, you must complete the first login process which includes manda
### Step 2: Access Admin Panel
1. Navigate to your admin panel URL (e.g., `http://your-domain.com:3001/admin` or `https://your-domain.com/admin`)
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
@@ -392,7 +417,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API
# Frontend (serves UI and /admin/*)
location / {
proxy_pass http://localhost:3000;
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;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
@@ -400,21 +434,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Protected photos and uploads
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin routes
location /admin {
proxy_pass http://localhost:3001;
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;
}
}
@@ -735,4 +759,4 @@ For issues and questions:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
- Steps to reproduce
+28 -4
View File
@@ -166,8 +166,10 @@ sudo ./setup.sh --native --unattended \
## 🌐 Access Methods
### Direct Access (Simplest)
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
- **Native**: `http://your-server:3001/admin` (admin panel)
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
- Backend/API: `http://your-server:3001` (API only; no UI routes)
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
### With Domain & HTTPS
If configured during setup:
@@ -175,9 +177,23 @@ If configured during setup:
- `https://your-domain.com/admin` - Admin panel
### Behind Existing Proxy
Add to your Nginx/Apache configuration:
Add to your Nginx/Apache configuration (split frontend vs backend):
```nginx
# Frontend (UI + /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -189,6 +205,14 @@ location / {
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
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;
}
```
## 📁 Managing Galleries
@@ -516,4 +540,4 @@ sudo ./setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
Binary file not shown.
@@ -0,0 +1,89 @@
exports.up = async function(knex) {
console.log('Running migration: 041_add_logo_customization_settings');
// Add default logo customization settings
const logoSettings = [
{
setting_key: 'branding_logo_size',
setting_value: JSON.stringify('medium'),
setting_type: 'branding',
description: 'Logo size: small, medium, large, xlarge, or custom',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_max_height',
setting_value: JSON.stringify(48),
setting_type: 'branding',
description: 'Maximum logo height in pixels (used when size is custom)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_position',
setting_value: JSON.stringify('left'),
setting_type: 'branding',
description: 'Logo position in header: left, center, right',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_header',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in gallery header',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_hero',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in hero section (for non-grid layouts)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_mode',
setting_value: JSON.stringify('logo_and_text'),
setting_type: 'branding',
description: 'Display mode: logo_only, text_only, logo_and_text',
created_at: new Date(),
updated_at: new Date()
}
];
// Insert settings that don't already exist
for (const setting of logoSettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert(setting);
console.log(`Added setting: ${setting.setting_key}`);
} else {
console.log(`Setting already exists: ${setting.setting_key}`);
}
}
console.log('Migration 041_add_logo_customization_settings completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 041_add_logo_customization_settings');
// Remove the logo customization settings
await knex('app_settings')
.whereIn('setting_key', [
'branding_logo_size',
'branding_logo_max_height',
'branding_logo_position',
'branding_logo_display_header',
'branding_logo_display_hero',
'branding_logo_display_mode'
])
.del();
console.log('Rollback of 041_add_logo_customization_settings completed');
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.106",
"version": "1.0.109",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.106",
"version": "1.0.109",
"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.106",
"version": "1.0.109",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+14 -2
View File
@@ -170,7 +170,13 @@ router.put('/branding', adminAuth, async (req, res) => {
watermark_size,
favicon_url,
logo_url,
watermark_logo_url
watermark_logo_url,
logo_size,
logo_max_height,
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
} = req.body;
const brandingSettings = {
@@ -184,7 +190,13 @@ router.put('/branding', adminAuth, async (req, res) => {
watermark_size,
favicon_url,
logo_url,
watermark_logo_url
watermark_logo_url,
logo_size,
logo_max_height,
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
};
// Handle favicon deletion if empty string or null is provided
+31 -18
View File
@@ -97,12 +97,42 @@ router.get('/:slug/info', async (req, res) => {
// Get all photos
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
const feedbackService = require('../services/feedbackService');
// First get all photos
const photos = await db('photos')
let photos = await db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
// Apply filtering if requested
if (filter && guest_id) {
let filters = {};
// Parse filter parameter
if (filter === 'liked') {
filters.liked = true;
} else if (filter === 'favorited') {
filters.favorited = true;
} else if (filter === 'liked,favorited' || filter === 'favorited,liked') {
filters.liked = true;
filters.favorited = true;
filters.operator = 'OR';
}
// Get filtered photo IDs
const filteredPhotoIds = await feedbackService.getFilteredPhotos(
req.event.id,
guest_id,
filters
);
// Filter photos to only include those with feedback
photos = photos.filter(photo => filteredPhotoIds.includes(photo.id));
}
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -150,13 +180,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
overlay_protection: req.event.overlay_protection !== false
};
console.log('[Gallery Photos] Event data:', {
id: req.event.id,
slug: req.params.slug,
protection_level: req.event.protection_level,
calculated_protection: protectionSettings.protection_level,
is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard')
});
res.json({
event: {
@@ -181,8 +204,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
return {
id: photo.id,
filename: photo.filename,
@@ -363,14 +384,6 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
}
});
// Test route
router.get('/:slug/photo-test/:photoId',
verifyGalleryAccess,
(req, res) => {
console.log('TEST ROUTE EXECUTED!');
res.json({ message: 'Test route works!', photoId: req.params.photoId });
}
);
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
+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);
+6
View File
@@ -42,6 +42,12 @@ router.get('/', async (req, res) => {
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
branding_logo_size: settingsObject.branding_logo_size || 'medium',
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
branding_logo_position: settingsObject.branding_logo_position || 'left',
branding_logo_display_header: settingsObject.branding_logo_display_header !== false,
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
+70
View File
@@ -390,6 +390,76 @@ class FeedbackService {
throw error;
}
}
/**
* Get filtered photos based on feedback criteria
* @param {number} eventId - Event ID
* @param {string} guestIdentifier - Guest identifier
* @param {object} filters - Filter criteria
* @param {boolean} filters.liked - Include liked photos
* @param {boolean} filters.favorited - Include favorited photos
* @param {string} filters.operator - 'AND' or 'OR' for multiple filters
* @returns {Promise<number[]>} Array of photo IDs that match criteria
*/
async getFilteredPhotos(eventId, guestIdentifier, filters = {}) {
try {
const { liked, favorited, operator = 'OR' } = filters;
// If no filters specified, return all photos
if (!liked && !favorited) {
const allPhotos = await db('photos')
.where('event_id', eventId)
.select('id');
return allPhotos.map(p => p.id);
}
// Build query based on filters
let query = db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('is_hidden', false);
// Apply filter logic
if (operator === 'AND' && liked && favorited) {
// For AND operation, we need photos that have both types of feedback
const likedPhotos = await db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('feedback_type', 'like')
.where('is_hidden', false)
.select('photo_id');
const favoritedPhotos = await db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('feedback_type', 'favorite')
.where('is_hidden', false)
.select('photo_id');
const likedIds = new Set(likedPhotos.map(p => p.photo_id));
const favoritedIds = new Set(favoritedPhotos.map(p => p.photo_id));
// Return intersection of both sets
return Array.from(likedIds).filter(id => favoritedIds.has(id));
} else {
// OR operation or single filter
const feedbackTypes = [];
if (liked) feedbackTypes.push('like');
if (favorited) feedbackTypes.push('favorite');
query.whereIn('feedback_type', feedbackTypes);
}
const filteredPhotos = await query
.distinct('photo_id')
.select('photo_id');
return filteredPhotos.map(p => p.photo_id);
} catch (error) {
logger.error('Error getting filtered photos:', error);
throw error;
}
}
}
module.exports = new FeedbackService();
+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');
}
+63
View File
@@ -0,0 +1,63 @@
// Test script for filter functionality
const axios = require('axios');
const API_URL = 'http://localhost:3001/api';
const TEST_SLUG = 'wedding-test-feedback-event-2025-09-02';
const TEST_PASSWORD = 'StrongTiger3610%';
async function testFilterFunctionality() {
try {
console.log('Testing filter functionality...\n');
// 1. Authenticate to get JWT token
console.log('1. Authenticating with gallery...');
const authResponse = await axios.post(`${API_URL}/auth/gallery-login`, {
slug: TEST_SLUG,
password: TEST_PASSWORD
});
const token = authResponse.data.token;
console.log('✅ Authentication successful\n');
// 2. Test fetching all photos (no filter)
console.log('2. Fetching all photos (no filter)...');
const allPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
headers: { Authorization: `Bearer ${token}` }
});
console.log(`✅ Found ${allPhotosResponse.data.photos.length} total photos\n`);
// 3. Test fetching with liked filter
console.log('3. Testing filter for liked photos...');
const guestId = 'test_guest_123';
const likedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
params: { filter: 'liked', guest_id: guestId },
headers: { Authorization: `Bearer ${token}` }
});
console.log(`✅ Found ${likedPhotosResponse.data.photos.length} liked photos for guest ${guestId}\n`);
// 4. Test fetching with favorited filter
console.log('4. Testing filter for favorited photos...');
const favoritedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
params: { filter: 'favorited', guest_id: guestId },
headers: { Authorization: `Bearer ${token}` }
});
console.log(`✅ Found ${favoritedPhotosResponse.data.photos.length} favorited photos for guest ${guestId}\n`);
// 5. Test combined filter
console.log('5. Testing combined filter (liked OR favorited)...');
const combinedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
params: { filter: 'liked,favorited', guest_id: guestId },
headers: { Authorization: `Bearer ${token}` }
});
console.log(`✅ Found ${combinedPhotosResponse.data.photos.length} photos that are liked OR favorited\n`);
console.log('🎉 All filter tests passed successfully!');
} catch (error) {
console.error('❌ Test failed:', error.response?.data || error.message);
process.exit(1);
}
}
// Run the test
testFilterFunctionality();
+9 -8
View File
@@ -41,13 +41,13 @@ services:
env_file: .env
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_HOST=${DB_HOST:-postgres}
- REDIS_HOST=redis
- PHOTOS_DIR=/app/storage/events
volumes:
- ./storage:/app/storage
- ./logs:/app/logs
- ./data:/app/data
- ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs
- ${APP_DATA}:/app/data
ports:
- "${BACKEND_PORT:-3001}:3000"
networks:
@@ -59,7 +59,8 @@ services:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
# Backend exposes /health on internal port 3000
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -68,8 +69,8 @@ services:
# 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}
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS.
ports:
- "${FRONTEND_PORT:-3000}:80"
networks:
@@ -110,4 +111,4 @@ volumes:
networks:
picpeak-network:
driver: bridge
driver: bridge
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.107",
"version": "1.0.109",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.107",
"version": "1.0.109",
"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.107",
"version": "1.0.109",
"type": "module",
"scripts": {
"dev": "vite",
@@ -126,11 +126,22 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
)}
</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>
);
};
@@ -0,0 +1,122 @@
import React from 'react';
import { Heart, Star } from 'lucide-react';
import { Button } from '../common';
import { useTranslation } from 'react-i18next';
export type FilterType = 'all' | 'liked' | 'favorited';
interface GalleryFilterProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
feedbackEnabled: boolean;
likeCount?: number;
favoriteCount?: number;
className?: string;
isMobile?: boolean;
}
export const GalleryFilter: React.FC<GalleryFilterProps> = ({
currentFilter,
onFilterChange,
feedbackEnabled,
likeCount = 0,
favoriteCount = 0,
className = '',
isMobile = false
}) => {
const { t } = useTranslation();
if (!feedbackEnabled) {
return null;
}
return (
<div className={`${className}`}>
{/* Mobile-optimized vertical layout */}
{isMobile ? (
<div className="space-y-2">
<div className="text-xs text-neutral-600 font-medium">
{t('gallery.feedbackFilter', 'Feedback Filter')}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs flex-1 min-w-[80px]"
>
{t('gallery.all', 'All')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
>
<Heart className="w-3 h-3" />
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
>
<Star className="w-3 h-3" />
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorites', 'Favorites')}</span>
</Button>
</div>
</div>
) : (
/* Desktop layout - inline with categories */
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-600 font-medium whitespace-nowrap">
{t('gallery.feedbackFilter', 'Feedback Filter')}:
</span>
<div className="flex gap-2">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs sm:text-sm"
>
{t('gallery.all', 'All')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Heart className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span>
{likeCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{likeCount}
</span>
)}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.favorited', 'Favorites')}</span>
{favoriteCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{favoriteCount}
</span>
)}
</Button>
</div>
</div>
)}
</div>
);
};
+104 -23
View File
@@ -23,6 +23,12 @@ interface GalleryLayoutProps {
footer_text?: string;
favicon_url?: string;
logo_url?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right';
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
};
showLogout?: boolean;
onLogout?: () => void;
@@ -56,6 +62,53 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
const headingFontFamily = theme.headingFontFamily || fontFamily;
// Calculate logo size classes based on settings
const getLogoSizeClass = (context: 'header' | 'hero') => {
const size = brandingSettings?.logo_size || 'medium';
const maxHeight = brandingSettings?.logo_max_height || 48;
if (size === 'custom') {
return { maxHeight: `${maxHeight}px`, height: 'auto' };
}
const sizeMap = {
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16',
medium: context === 'header' ? 'h-8 sm:h-10 lg:h-12' : 'h-16 sm:h-20 lg:h-24',
large: context === 'header' ? 'h-10 sm:h-12 lg:h-16' : 'h-20 sm:h-24 lg:h-32',
xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
};
return sizeMap[size] || sizeMap.medium;
};
// Determine logo position classes
const getLogoPositionClass = () => {
const position = brandingSettings?.logo_position || 'left';
return {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end'
}[position];
};
// Check if logo should be displayed
const shouldShowLogo = (context: 'header' | 'hero') => {
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
if (displayMode === 'text_only') return false;
if (context === 'header') {
return brandingSettings?.logo_display_header !== false;
} else {
return brandingSettings?.logo_display_hero !== false;
}
};
// Check if company name should be displayed
const shouldShowCompanyName = () => {
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
return displayMode !== 'logo_only';
};
return (
<div className="min-h-screen bg-neutral-50">
{/* Dynamic Favicon */}
@@ -122,16 +175,31 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="flex-shrink-0">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
/>
</div>
{shouldShowLogo('header') && (
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
{brandingSettings.company_name}
</span>
)}
</div>
)}
{!shouldShowLogo('header') && shouldShowCompanyName() && brandingSettings?.company_name && (
<div className={`flex-shrink-0 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<span className="text-lg font-semibold text-neutral-900">
{brandingSettings.company_name || 'PicPeak'}
</span>
</div>
)}
</div>
{/* Center - Event info */}
@@ -277,19 +345,32 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
<div className="text-center max-w-4xl mx-auto">
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="mb-6">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
</div>
{shouldShowLogo('hero') && (
<div className="mb-6">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
style={typeof getLogoSizeClass('hero') === 'object' ?
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } :
{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }
}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
<div className="mt-3 text-xl sm:text-2xl font-semibold text-white/90" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
{brandingSettings.company_name}
</div>
)}
</div>
)}
{!shouldShowLogo('hero') && shouldShowCompanyName() && brandingSettings?.company_name && (
<div className="mb-6 text-2xl sm:text-3xl font-bold text-white" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
{brandingSettings.company_name || 'PicPeak'}
</div>
)}
{/* Event Name */}
<h1
@@ -3,6 +3,7 @@ import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check,
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
import { GalleryFilter, type FilterType } from './GalleryFilter';
interface GallerySidebarProps {
isOpen: boolean;
@@ -28,6 +29,11 @@ interface GallerySidebarProps {
galleryLayout?: string;
allowUploads?: boolean;
onUploadClick?: () => void;
feedbackEnabled?: boolean;
filterType?: FilterType;
onFilterChange?: (filter: FilterType) => void;
likeCount?: number;
favoriteCount?: number;
}
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
@@ -53,7 +59,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
isMobile,
galleryLayout,
allowUploads,
onUploadClick
onUploadClick,
feedbackEnabled = false,
filterType = 'all',
onFilterChange,
likeCount = 0,
favoriteCount = 0
}) => {
const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null);
@@ -201,6 +212,23 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div>
)}
{/* Feedback Filter Section */}
{feedbackEnabled && onFilterChange && (
<div className="p-4 border-b border-neutral-200">
<GalleryFilter
currentFilter={filterType}
onFilterChange={(filter) => {
onFilterChange(filter);
if (isMobile) onClose();
}}
feedbackEnabled={feedbackEnabled}
likeCount={likeCount}
favoriteCount={favoriteCount}
className="w-full"
/>
</div>
)}
{/* Categories Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && categories.length > 0 && (
<div className="p-4 border-b border-neutral-200">
@@ -13,6 +13,7 @@ import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -55,9 +56,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all');
const [guestId, setGuestId] = useState<string>('');
// Fetch photos
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Generate a unique guest ID for this session
useEffect(() => {
// Use existing guest ID from localStorage or generate new one
let storedGuestId = localStorage.getItem('gallery_guest_id');
if (!storedGuestId) {
storedGuestId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('gallery_guest_id', storedGuestId);
}
setGuestId(storedGuestId);
}, []);
// Fetch photos with filter support
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, filterType, guestId);
// Set protection level when data is available
useEffect(() => {
@@ -122,7 +136,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
console.log('Feedback settings response:', response.data);
return response.data;
} catch (error) {
console.error('Error fetching feedback settings:', error);
@@ -136,7 +149,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Update feedbackEnabled when settings change
useEffect(() => {
if (feedbackSettings) {
console.log('Setting feedbackEnabled to:', feedbackSettings.feedback_enabled);
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
}
}, [feedbackSettings]);
@@ -424,6 +436,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
galleryLayout={theme.galleryLayout}
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
onUploadClick={() => setShowUploadModal(true)}
feedbackEnabled={feedbackEnabled}
filterType={filterType}
onFilterChange={setFilterType}
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0}
/>
) : null}
@@ -510,6 +527,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
// Feedback filter props
feedbackEnabled={feedbackEnabled}
currentFilter={filterType}
onFilterChange={setFilterType}
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0}
/>
</div>
) : null}
@@ -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,10 @@ 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);
return data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
@@ -104,6 +107,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 +122,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
isLiked={isLiked}
likeCount={likeCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onLikeChange={handleLikeChange}
/>
)}
@@ -128,6 +133,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onFavoriteChange={handleFavoriteChange}
/>
)}
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { Search, SortAsc, Grid } from 'lucide-react';
import { Search, SortAsc, Grid, Heart, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import type { FilterType } from './GalleryFilter';
interface PhotoCategory {
id: number;
@@ -13,6 +14,8 @@ interface PhotoCategory {
interface Photo {
id: number;
category_id?: number;
like_count?: number;
favorite_count?: number;
}
interface PhotoFilterBarProps {
@@ -25,6 +28,12 @@ interface PhotoFilterBarProps {
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
photoCount: number;
// Feedback filter props
feedbackEnabled?: boolean;
currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void;
likeCount?: number;
favoriteCount?: number;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -37,6 +46,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
sortBy,
onSortChange,
photoCount,
feedbackEnabled = false,
currentFilter = 'all',
onFilterChange,
likeCount = 0,
favoriteCount = 0,
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
@@ -44,7 +58,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
return (
<div className="space-y-4">
{/* Search and Sort */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex flex-col md:flex-row gap-3 md:gap-4">
{/* Search Bar */}
<div className="flex-1">
<Input
@@ -53,7 +67,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="text-sm sm:text-base"
className="text-sm md:text-base"
/>
</div>
@@ -64,9 +78,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
className="w-full sm:w-auto text-sm sm:text-base"
className="w-full md:w-auto text-sm md:text-base"
>
<span className="hidden sm:inline">{t('common.sortBy')} </span>
<span className="hidden md:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
@@ -74,7 +88,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</Button>
{showSortMenu && (
<div className="absolute right-0 sm:right-auto sm:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<div className="absolute right-0 md:right-auto md:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<button
onClick={() => {
onSortChange('date');
@@ -124,18 +138,19 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div>
</div>
{/* Category Filter */}
{categories && categories.length > 0 && (
<div className="space-y-3">
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-0">
{/* Category and Feedback Filters */}
<div className="space-y-3">
{/* Categories Row */}
{categories && categories.length > 0 && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
className="text-xs sm:text-sm whitespace-nowrap"
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{t('gallery.allPhotos')} ({photos.length})
</Button>
@@ -149,21 +164,92 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs sm:text-sm whitespace-nowrap"
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
{/* Feedback Filter - Inline on desktop, below on mobile/tablet */}
{feedbackEnabled && onFilterChange && (
<>
{/* Desktop: Divider and inline filter - only on larger screens */}
<div className="hidden lg:flex items-center gap-2 ml-2 pl-2 border-l border-neutral-300">
<span className="text-sm text-neutral-600 whitespace-nowrap">{t('gallery.feedbackFilter')}:</span>
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs sm:text-sm"
>
{t('gallery.all')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Heart className="w-3 h-3" />
{likeCount > 0 && <span>{likeCount}</span>}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Star className="w-3 h-3" />
{favoriteCount > 0 && <span>{favoriteCount}</span>}
</Button>
</div>
</>
)}
</div>
</div>
<p className="text-xs sm:text-sm text-neutral-600 flex-shrink-0">
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
</p>
</div>
</div>
)}
)}
{/* Mobile/Tablet: Feedback Filter below categories */}
{feedbackEnabled && onFilterChange && (
<div className="flex lg:hidden items-center gap-2">
<span className="text-xs text-neutral-600">{t('gallery.feedbackFilter')}:</span>
<div className="flex gap-1 flex-1">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs flex-1"
>
{t('gallery.all')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs flex-1 flex items-center justify-center gap-1"
>
<Heart className="w-3 h-3" />
{likeCount > 0 && <span>{likeCount}</span>}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 flex items-center justify-center gap-1"
>
<Star className="w-3 h-3" />
{favoriteCount > 0 && <span>{favoriteCount}</span>}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
@@ -35,9 +35,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [touchDistance, setTouchDistance] = useState<number | null>(null);
const [showFeedback, setShowFeedback] = useState(false);
// Debug logging
console.log('PhotoLightbox feedbackEnabled:', feedbackEnabled);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -288,7 +285,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{feedbackEnabled && (
<button
onClick={() => {
console.log('Feedback button clicked, current feedbackEnabled:', feedbackEnabled);
setShowFeedback(!showFeedback);
}}
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
+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')}
/>
</>
);
};
+3 -3
View File
@@ -11,10 +11,10 @@ export const useGalleryInfo = (slug: string, token?: string) => {
});
};
export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
export const useGalleryPhotos = (slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string, enabled: boolean = true) => {
return useQuery({
queryKey: ['gallery-photos', slug],
queryFn: () => galleryService.getGalleryPhotos(slug),
queryKey: ['gallery-photos', slug, filter, guestId],
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
+6
View File
@@ -134,6 +134,12 @@
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"allPhotos": "All Photos",
"filter": "Filter",
"feedbackFilter": "Feedback Filter",
"all": "All",
"liked": "Liked",
"favorited": "Favorited",
"favorites": "Favorites",
"downloadSelected": "Download Selected",
"shareGallery": "Share Gallery",
"needHelp": "Need help? Contact us at",
+175
View File
@@ -144,6 +144,21 @@ export const BrandingPage: React.FC = () => {
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const logoUrl = await settingsService.uploadLogo(file);
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
setCurrentTheme(prev => ({ ...prev, logoUrl }));
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload logo:', error);
toast.error(t('toast.uploadError'));
}
}
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -316,6 +331,166 @@ export const BrandingPage: React.FC = () => {
</div>
</div>
{/* Logo Customization Settings */}
<div className="mt-6 pt-6 border-t border-neutral-200">
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.logoCustomization', 'Logo Customization')}</h3>
<div className="space-y-4">
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logo', 'Logo')}
</label>
<div className="flex items-center gap-4">
{brandingSettings.logo_url && (
<div className="relative">
<img
src={brandingSettings.logo_url.startsWith('http') ? brandingSettings.logo_url : buildResourceUrl(brandingSettings.logo_url)}
alt="Logo"
className="h-16 object-contain bg-neutral-100 rounded p-2"
/>
<button
type="button"
onClick={() => handleBrandingChange('logo_url', '')}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
>
×
</button>
</div>
)}
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
/>
<span className="btn-secondary inline-flex items-center">
<Upload className="w-4 h-4 mr-2" />
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</span>
</label>
</div>
<p className="text-xs text-neutral-600 mt-1">
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
</p>
</div>
{/* Logo Size */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoSize', 'Logo Size')}
</label>
<select
value={brandingSettings.logo_size || 'medium'}
onChange={(e) => handleBrandingChange('logo_size', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="small">{t('branding.logoSizeSmall', 'Small (32px)')}</option>
<option value="medium">{t('branding.logoSizeMedium', 'Medium (48px)')}</option>
<option value="large">{t('branding.logoSizeLarge', 'Large (64px)')}</option>
<option value="xlarge">{t('branding.logoSizeXLarge', 'Extra Large (96px)')}</option>
<option value="custom">{t('branding.logoSizeCustom', 'Custom')}</option>
</select>
</div>
{/* Custom Height (only shown when size is custom) */}
{brandingSettings.logo_size === 'custom' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoMaxHeight', 'Maximum Height (pixels)')}
</label>
<input
type="number"
min="20"
max="200"
value={brandingSettings.logo_max_height || 48}
onChange={(e) => handleBrandingChange('logo_max_height', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-neutral-600 mt-1">
{t('branding.logoMaxHeightHelp', 'Set a custom maximum height for the logo (20-200 pixels)')}
</p>
</div>
)}
{/* Logo Position */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoPosition', 'Logo Position in Header')}
</label>
<div className="flex gap-2">
{(['left', 'center', 'right'] as const).map((position) => (
<button
key={position}
type="button"
onClick={() => handleBrandingChange('logo_position', position)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
brandingSettings.logo_position === position
? 'bg-primary-600 text-white'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
{t(`branding.position${position.charAt(0).toUpperCase() + position.slice(1)}`, position.charAt(0).toUpperCase() + position.slice(1))}
</button>
))}
</div>
</div>
{/* Display Mode */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.logoDisplayMode', 'Display Mode')}
</label>
<select
value={brandingSettings.logo_display_mode || 'logo_and_text'}
onChange={(e) => handleBrandingChange('logo_display_mode', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="logo_only">{t('branding.logoOnly', 'Logo Only')}</option>
<option value="text_only">{t('branding.textOnly', 'Company Name Only')}</option>
<option value="logo_and_text">{t('branding.logoAndText', 'Logo and Company Name')}</option>
</select>
</div>
{/* Display Options */}
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.logo_display_header !== false}
onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">
{t('branding.showLogoInHeader', 'Show logo in gallery header')}
</span>
<p className="text-xs text-neutral-600">
{t('branding.showLogoInHeaderHelp', 'Display the logo in the main header bar')}
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.logo_display_hero !== false}
onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">
{t('branding.showLogoInHero', 'Show logo in hero section')}
</span>
<p className="text-xs text-neutral-600">
{t('branding.showLogoInHeroHelp', 'Display the logo in hero sections (for non-grid layouts)')}
</p>
</div>
</label>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
+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>
+7 -2
View File
@@ -16,8 +16,13 @@ export const galleryService = {
},
// Get gallery photos (requires auth)
async getGalleryPhotos(slug: string): Promise<GalleryData> {
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
async getGalleryPhotos(slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string): Promise<GalleryData> {
const params: any = {};
if (filter && filter !== 'all' && guestId) {
params.filter = filter;
params.guest_id = guestId;
}
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
return response.data;
},
+13 -1
View File
@@ -12,6 +12,12 @@ export interface BrandingSettings {
watermark_logo_url?: string;
logo_url?: string;
favicon_url?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right';
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
}
export interface ThemeSettings {
@@ -215,7 +221,13 @@ export const settingsService = {
watermark_size: rawSettings.branding_watermark_size || 15,
watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined,
logo_url: rawSettings.branding_logo_url || undefined,
favicon_url: rawSettings.branding_favicon_url || undefined
favicon_url: rawSettings.branding_favicon_url || undefined,
logo_size: rawSettings.branding_logo_size || 'medium',
logo_max_height: rawSettings.branding_logo_max_height || 48,
logo_position: rawSettings.branding_logo_position || 'left',
logo_display_header: rawSettings.branding_logo_display_header !== false,
logo_display_hero: rawSettings.branding_logo_display_hero !== false,
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text'
};
},