Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d826accdc | |||
| ceefe4f5a7 | |||
| e9171c7115 | |||
| 758c085467 | |||
| ecbc48815d | |||
| e91b138154 | |||
| dad1787aad | |||
| 909e760447 | |||
| 41857ec499 |
@@ -34,9 +34,15 @@ SMTP_PASS=your-app-specific-password
|
|||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
|
||||||
# Application URLs
|
# Application URLs
|
||||||
|
# Use full origin with scheme, no trailing slash.
|
||||||
|
# Admin UI is served by the frontend at /admin.
|
||||||
FRONTEND_URL=https://yourdomain.com
|
FRONTEND_URL=https://yourdomain.com
|
||||||
ADMIN_URL=https://yourdomain.com:3001
|
ADMIN_URL=https://yourdomain.com
|
||||||
VITE_API_URL=https://yourdomain.com:3001/api
|
|
||||||
|
# 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)
|
# Port Configuration (optional)
|
||||||
# BACKEND_PORT=3001
|
# BACKEND_PORT=3001
|
||||||
@@ -50,4 +56,16 @@ TZ=UTC
|
|||||||
# Analytics (Optional - Umami)
|
# Analytics (Optional - Umami)
|
||||||
VITE_UMAMI_URL=
|
VITE_UMAMI_URL=
|
||||||
VITE_UMAMI_WEBSITE_ID=
|
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.
|
||||||
|
|||||||
@@ -72,3 +72,12 @@ logs/
|
|||||||
storage/
|
storage/
|
||||||
data/
|
data/
|
||||||
certbot/
|
certbot/
|
||||||
|
|
||||||
|
# Ignore local contributor guide copy
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
|
# Local artifacts from browser tooling
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Local SQLite files in backend
|
||||||
|
backend/*.sqlite*
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 181 KiB |
@@ -170,8 +170,28 @@ Update `.env` with:
|
|||||||
- `REDIS_PASSWORD` - Redis password
|
- `REDIS_PASSWORD` - Redis password
|
||||||
- `SMTP_*` - Email configuration
|
- `SMTP_*` - Email configuration
|
||||||
- **URL Configuration** (for backend CORS):
|
- **URL Configuration** (for backend CORS):
|
||||||
- `FRONTEND_URL` - Frontend URL (e.g., `http://localhost:3000` for Docker)
|
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||||
- `ADMIN_URL` - Admin URL (e.g., `http://localhost:3000` for Docker)
|
- 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 don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||||
|
|
||||||
### Frontend Configuration (frontend/.env)
|
### Frontend Configuration (frontend/.env)
|
||||||
Create `frontend/.env` from `frontend/.env.example`:
|
Create `frontend/.env` from `frontend/.env.example`:
|
||||||
@@ -181,9 +201,10 @@ cp frontend/.env.example frontend/.env
|
|||||||
|
|
||||||
Update `frontend/.env` with:
|
Update `frontend/.env` with:
|
||||||
- `VITE_API_URL` - Backend API URL
|
- `VITE_API_URL` - Backend API URL
|
||||||
- For Docker deployment: `http://localhost:3001/api`
|
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||||
- For non-Docker local dev: `http://localhost:3001`
|
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||||
- For production with reverse proxy: `/api`
|
|
||||||
|
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**:
|
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||||
@@ -246,8 +267,8 @@ docker compose ps
|
|||||||
### Access Points
|
### Access Points
|
||||||
|
|
||||||
By default, services are exposed on:
|
By default, services are exposed on:
|
||||||
- Frontend: http://localhost:3000
|
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||||
- Backend/API: http://localhost:3001
|
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||||
- PostgreSQL: localhost:5432 (if needed)
|
- PostgreSQL: localhost:5432 (if needed)
|
||||||
- Redis: localhost:6379 (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
|
### 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:
|
2. Login using:
|
||||||
- **Email**: `admin@example.com` (or your custom admin email)
|
- **Email**: `admin@example.com` (or your custom admin email)
|
||||||
- **Password**: The auto-generated password from the logs
|
- **Password**: The auto-generated password from the logs
|
||||||
@@ -392,7 +417,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 {
|
location /api {
|
||||||
proxy_pass http://localhost:3001;
|
proxy_pass http://localhost:3001;
|
||||||
proxy_set_header Host $host;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Protected photos and uploads
|
|
||||||
location ~ ^/(photos|thumbnails|uploads) {
|
location ~ ^/(photos|thumbnails|uploads) {
|
||||||
proxy_pass http://localhost:3001;
|
proxy_pass http://localhost:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,4 +759,4 @@ For issues and questions:
|
|||||||
- Error messages
|
- Error messages
|
||||||
- Log output
|
- Log output
|
||||||
- Environment details (without secrets)
|
- Environment details (without secrets)
|
||||||
- Steps to reproduce
|
- Steps to reproduce
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ sudo ./setup.sh --native --unattended \
|
|||||||
## 🌐 Access Methods
|
## 🌐 Access Methods
|
||||||
|
|
||||||
### Direct Access (Simplest)
|
### Direct Access (Simplest)
|
||||||
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
|
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
|
||||||
- **Native**: `http://your-server:3001/admin` (admin panel)
|
- 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
|
### With Domain & HTTPS
|
||||||
If configured during setup:
|
If configured during setup:
|
||||||
@@ -175,9 +177,23 @@ If configured during setup:
|
|||||||
- `https://your-domain.com/admin` - Admin panel
|
- `https://your-domain.com/admin` - Admin panel
|
||||||
|
|
||||||
### Behind Existing Proxy
|
### Behind Existing Proxy
|
||||||
Add to your Nginx/Apache configuration:
|
Add to your Nginx/Apache configuration (split frontend vs backend):
|
||||||
```nginx
|
```nginx
|
||||||
|
# Frontend (UI + /admin/*)
|
||||||
location / {
|
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_pass http://localhost:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -189,6 +205,14 @@ location / {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
client_max_body_size 100M;
|
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
|
## 📁 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)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -170,7 +170,13 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
watermark_size,
|
watermark_size,
|
||||||
favicon_url,
|
favicon_url,
|
||||||
logo_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;
|
} = req.body;
|
||||||
|
|
||||||
const brandingSettings = {
|
const brandingSettings = {
|
||||||
@@ -184,7 +190,13 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
watermark_size,
|
watermark_size,
|
||||||
favicon_url,
|
favicon_url,
|
||||||
logo_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
|
// Handle favicon deletion if empty string or null is provided
|
||||||
|
|||||||
@@ -97,12 +97,42 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
// Get all photos
|
// Get all photos
|
||||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Get filter parameters from query
|
||||||
|
const { filter, guest_id } = req.query;
|
||||||
|
const feedbackService = require('../services/feedbackService');
|
||||||
|
|
||||||
// First get all photos
|
// First get all photos
|
||||||
const photos = await db('photos')
|
let photos = await db('photos')
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select('photos.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.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
|
// Then get comment counts separately
|
||||||
const commentCounts = await db('photo_feedback')
|
const commentCounts = await db('photo_feedback')
|
||||||
.whereIn('photo_id', photos.map(p => p.id))
|
.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
|
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({
|
res.json({
|
||||||
event: {
|
event: {
|
||||||
@@ -181,8 +204,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
`/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 {
|
return {
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
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)
|
// View single photo (with watermark if enabled)
|
||||||
router.get('/:slug/photo/:photoId',
|
router.get('/:slug/photo/:photoId',
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ router.get('/', async (req, res) => {
|
|||||||
branding_watermark_size: settingsObject.branding_watermark_size || 15,
|
branding_watermark_size: settingsObject.branding_watermark_size || 15,
|
||||||
branding_favicon_url: settingsObject.branding_favicon_url || '',
|
branding_favicon_url: settingsObject.branding_favicon_url || '',
|
||||||
branding_logo_url: settingsObject.branding_logo_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,
|
theme_config: settingsObject.theme_config || null,
|
||||||
default_language: settingsObject.general_default_language || 'en',
|
default_language: settingsObject.general_default_language || 'en',
|
||||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||||
|
|||||||
@@ -390,6 +390,76 @@ class FeedbackService {
|
|||||||
throw error;
|
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();
|
module.exports = new FeedbackService();
|
||||||
@@ -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();
|
||||||
@@ -41,13 +41,13 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_HOST=postgres
|
- DB_HOST=${DB_HOST:-postgres}
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- PHOTOS_DIR=/app/storage/events
|
- PHOTOS_DIR=/app/storage/events
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/app/storage
|
- ${APP_STORAGE}:/app/storage
|
||||||
- ./logs:/app/logs
|
- ${LOGS}:/app/logs
|
||||||
- ./data:/app/data
|
- ${APP_DATA}:/app/data
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3001}:3000"
|
- "${BACKEND_PORT:-3001}:3000"
|
||||||
networks:
|
networks:
|
||||||
@@ -59,7 +59,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -68,8 +69,8 @@ services:
|
|||||||
# Use pre-built image from GitHub Container Registry
|
# Use pre-built image from GitHub Container Registry
|
||||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||||
container_name: picpeak-frontend
|
container_name: picpeak-frontend
|
||||||
environment:
|
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
|
||||||
- VITE_API_URL=${FRONTEND_API_URL:-http://localhost:3001}
|
# Prefer keeping API base as '/api' in builds to avoid CORS.
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
networks:
|
networks:
|
||||||
@@ -110,4 +111,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
picpeak-network:
|
picpeak-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.108",
|
"version": "1.0.109",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,6 +23,12 @@ interface GalleryLayoutProps {
|
|||||||
footer_text?: string;
|
footer_text?: string;
|
||||||
favicon_url?: string;
|
favicon_url?: string;
|
||||||
logo_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;
|
showLogout?: boolean;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@@ -56,6 +62,53 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="min-h-screen bg-neutral-50">
|
||||||
{/* Dynamic Favicon */}
|
{/* Dynamic Favicon */}
|
||||||
@@ -122,16 +175,31 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||||
<div className="flex-shrink-0">
|
{shouldShowLogo('header') && (
|
||||||
<img
|
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||||
src={brandingSettings?.logo_url ?
|
<img
|
||||||
buildResourceUrl(brandingSettings.logo_url) :
|
src={brandingSettings?.logo_url ?
|
||||||
'/picpeak-logo-transparent.png'
|
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"
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
/>
|
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Center - Event info */}
|
{/* 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="container py-12 sm:py-16 lg:py-20 relative z-10">
|
||||||
<div className="text-center max-w-4xl mx-auto">
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||||
<div className="mb-6">
|
{shouldShowLogo('hero') && (
|
||||||
<img
|
<div className="mb-6">
|
||||||
src={brandingSettings?.logo_url ?
|
<img
|
||||||
buildResourceUrl(brandingSettings.logo_url) :
|
src={brandingSettings?.logo_url ?
|
||||||
'/picpeak-logo-transparent.png'
|
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"
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
style={{
|
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
|
||||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
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))' }
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
{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 */}
|
{/* Event Name */}
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check,
|
|||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { PhotoCategory } from '../../types';
|
import { PhotoCategory } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GalleryFilter, type FilterType } from './GalleryFilter';
|
||||||
|
|
||||||
interface GallerySidebarProps {
|
interface GallerySidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -28,6 +29,11 @@ interface GallerySidebarProps {
|
|||||||
galleryLayout?: string;
|
galleryLayout?: string;
|
||||||
allowUploads?: boolean;
|
allowUploads?: boolean;
|
||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
|
filterType?: FilterType;
|
||||||
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||||
@@ -53,7 +59,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
isMobile,
|
isMobile,
|
||||||
galleryLayout,
|
galleryLayout,
|
||||||
allowUploads,
|
allowUploads,
|
||||||
onUploadClick
|
onUploadClick,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
filterType = 'all',
|
||||||
|
onFilterChange,
|
||||||
|
likeCount = 0,
|
||||||
|
favoriteCount = 0
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -201,6 +212,23 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Categories Section - Hidden for carousel layout */}
|
||||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="p-4 border-b border-neutral-200">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GalleryLayout } from './GalleryLayout';
|
|||||||
import { GallerySidebar } from './GallerySidebar';
|
import { GallerySidebar } from './GallerySidebar';
|
||||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||||
|
import type { FilterType } from './GalleryFilter';
|
||||||
import { analyticsService } from '../../services/analytics.service';
|
import { analyticsService } from '../../services/analytics.service';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
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 [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
||||||
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
const [guestId, setGuestId] = useState<string>('');
|
||||||
|
|
||||||
// Fetch photos
|
// Generate a unique guest ID for this session
|
||||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
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
|
// Set protection level when data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +136,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
try {
|
try {
|
||||||
// Use public endpoint to get feedback settings
|
// Use public endpoint to get feedback settings
|
||||||
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||||
console.log('Feedback settings response:', response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching feedback settings:', 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
|
// Update feedbackEnabled when settings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackSettings) {
|
if (feedbackSettings) {
|
||||||
console.log('Setting feedbackEnabled to:', feedbackSettings.feedback_enabled);
|
|
||||||
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
|
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
|
||||||
}
|
}
|
||||||
}, [feedbackSettings]);
|
}, [feedbackSettings]);
|
||||||
@@ -424,6 +436,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
galleryLayout={theme.galleryLayout}
|
galleryLayout={theme.galleryLayout}
|
||||||
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
||||||
onUploadClick={() => setShowUploadModal(true)}
|
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}
|
) : null}
|
||||||
|
|
||||||
@@ -510,6 +527,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
photoCount={filteredPhotos.length}
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
queryKey: ['gallery-feedback-settings', gallerySlug],
|
queryKey: ['gallery-feedback-settings', gallerySlug],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
|
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
|
||||||
console.log('PhotoFeedback received settings:', data);
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Button, Input } from '../common';
|
import { Button, Input } from '../common';
|
||||||
|
import type { FilterType } from './GalleryFilter';
|
||||||
|
|
||||||
interface PhotoCategory {
|
interface PhotoCategory {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,6 +14,8 @@ interface PhotoCategory {
|
|||||||
interface Photo {
|
interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
|
like_count?: number;
|
||||||
|
favorite_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhotoFilterBarProps {
|
interface PhotoFilterBarProps {
|
||||||
@@ -25,6 +28,12 @@ interface PhotoFilterBarProps {
|
|||||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
||||||
photoCount: number;
|
photoCount: number;
|
||||||
|
// Feedback filter props
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
|
currentFilter?: FilterType;
|
||||||
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||||
@@ -37,6 +46,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
sortBy,
|
sortBy,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
photoCount,
|
photoCount,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
currentFilter = 'all',
|
||||||
|
onFilterChange,
|
||||||
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
@@ -44,7 +58,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search and Sort */}
|
{/* 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 */}
|
{/* Search Bar */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -53,7 +67,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="text-sm sm:text-base"
|
className="text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,9 +78,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
size="md"
|
size="md"
|
||||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
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 === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
|
||||||
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
||||||
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
|
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
|
||||||
@@ -74,7 +88,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showSortMenu && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSortChange('date');
|
onSortChange('date');
|
||||||
@@ -124,18 +138,19 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category and Feedback Filters */}
|
||||||
{categories && categories.length > 0 && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-3">
|
{/* Categories Row */}
|
||||||
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
|
{categories && categories.length > 0 && (
|
||||||
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-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">
|
<div className="flex items-center gap-2 min-w-max">
|
||||||
<Button
|
<Button
|
||||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCategoryChange(null)}
|
onClick={() => onCategoryChange(null)}
|
||||||
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
|
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||||
className="text-xs sm:text-sm whitespace-nowrap"
|
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||||
>
|
>
|
||||||
{t('gallery.allPhotos')} ({photos.length})
|
{t('gallery.allPhotos')} ({photos.length})
|
||||||
</Button>
|
</Button>
|
||||||
@@ -149,21 +164,92 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCategoryChange(category.id)}
|
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})
|
{category.name} ({categoryPhotoCount})
|
||||||
</Button>
|
</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>
|
||||||
</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')}
|
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(false);
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('PhotoLightbox feedbackEnabled:', feedbackEnabled);
|
|
||||||
|
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
|
|
||||||
@@ -288,7 +285,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
{feedbackEnabled && (
|
{feedbackEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('Feedback button clicked, current feedbackEnabled:', feedbackEnabled);
|
|
||||||
setShowFeedback(!showFeedback);
|
setShowFeedback(!showFeedback);
|
||||||
}}
|
}}
|
||||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
|||||||
@@ -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({
|
return useQuery({
|
||||||
queryKey: ['gallery-photos', slug],
|
queryKey: ['gallery-photos', slug, filter, guestId],
|
||||||
queryFn: () => galleryService.getGalleryPhotos(slug),
|
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
|
||||||
enabled,
|
enabled,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|||||||
@@ -134,6 +134,12 @@
|
|||||||
"sortByName": "Sort by Name",
|
"sortByName": "Sort by Name",
|
||||||
"sortBySize": "Sort by Size",
|
"sortBySize": "Sort by Size",
|
||||||
"allPhotos": "All Photos",
|
"allPhotos": "All Photos",
|
||||||
|
"filter": "Filter",
|
||||||
|
"feedbackFilter": "Feedback Filter",
|
||||||
|
"all": "All",
|
||||||
|
"liked": "Liked",
|
||||||
|
"favorited": "Favorited",
|
||||||
|
"favorites": "Favorites",
|
||||||
"downloadSelected": "Download Selected",
|
"downloadSelected": "Download Selected",
|
||||||
"shareGallery": "Share Gallery",
|
"shareGallery": "Share Gallery",
|
||||||
"needHelp": "Need help? Contact us at",
|
"needHelp": "Need help? Contact us at",
|
||||||
|
|||||||
@@ -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 handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -316,6 +331,166 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export const galleryService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
async getGalleryPhotos(slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string): Promise<GalleryData> {
|
||||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export interface BrandingSettings {
|
|||||||
watermark_logo_url?: string;
|
watermark_logo_url?: string;
|
||||||
logo_url?: string;
|
logo_url?: string;
|
||||||
favicon_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 {
|
export interface ThemeSettings {
|
||||||
@@ -215,7 +221,13 @@ export const settingsService = {
|
|||||||
watermark_size: rawSettings.branding_watermark_size || 15,
|
watermark_size: rawSettings.branding_watermark_size || 15,
|
||||||
watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined,
|
watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined,
|
||||||
logo_url: rawSettings.branding_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'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||