Compare commits

...

52 Commits

Author SHA1 Message Date
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
Gitea Actions Bot eb626be22c chore: bump version to 1.0.130 (backend + frontend) 2025-09-19 14:47:37 +00:00
paul aaaf59817b fix: stabilize uploads and guest feedback filters
Mirror to GitHub / mirror (push) Successful in 1m54s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m49s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-19 16:39:18 +02:00
paul 2a4d38813f feat: overhaul public landing page and backup tooling 2025-09-19 16:39:18 +02:00
Gitea Actions Bot ad9c6d63d3 chore: bump backend version to 1.0.129 2025-09-18 14:54:28 +00:00
paul 8c77b30de6 Default auth cookies to non-secure for HTTP installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m36s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 16:48:14 +02:00
Gitea Actions Bot e51347d0a1 chore: bump version to 1.0.128 (backend + frontend) 2025-09-18 14:05:43 +00:00
paul 71e7179145 Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 15:59:26 +02:00
Gitea Actions Bot bda76ff513 chore: bump backend version to 1.0.125 2025-09-18 10:49:54 +00:00
paul 097ce2c205 Fix native install schema gaps (closes #20)
Mirror to GitHub / mirror (push) Successful in 1m38s
Test and Lint / backend-test (push) Successful in 1m44s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Failing after 1m20s
Version and Release / trigger-drone (push) Has been skipped
2025-09-18 12:42:05 +02:00
Gitea Actions Bot 1d8be3d840 chore: bump frontend version to 1.0.127 2025-09-17 21:40:18 +00:00
paul aebb8e66cb Make lightbox feedback panel sticky on desktop
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m17s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-17 23:33:43 +02:00
Gitea Actions Bot ed2a278da2 chore: bump frontend version to 1.0.126 2025-09-17 21:22:44 +00:00
paul db2f5da66a Ensure gallery comment filter hides moderated comments
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m25s
Version and Release / version-bump (push) Successful in 1m46s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-17 23:15:28 +02:00
paul 19f8facc49 Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
2025-09-17 22:27:13 +02:00
paul b03760ab01 feat(gallery/filters): add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries 2025-09-16 10:11:08 +02:00
paul 526dcd8dfc fix(gallery/filters): always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier 2025-09-16 09:57:35 +02:00
paul 5b2561b6f1 fix(gallery/filters): make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. 2025-09-16 09:28:12 +02:00
paul 3a6d06192a fix(gallery): feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) 2025-09-16 09:15:47 +02:00
paul 4b64b80b20 ui(gallery): feedback filter headline + horizontal compact icons (desktop+mobile); render only when feedback enabled 2025-09-16 09:11:32 +02:00
paul ff89f96e31 fix(gallery/sidebar): compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact 2025-09-16 09:03:24 +02:00
paul 465f997752 feat(gallery): compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: #19
Mirror to GitHub / mirror (push) Successful in 1m57s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 55s
Version and Release / trigger-drone (push) Has been skipped
2025-09-15 22:59:21 +02:00
paul 6948aaa92a feat(gallery): always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: #19 2025-09-15 22:59:21 +02:00
paul 4c7b49a5f6 fix(admin/feedback): use correct event id when rendering photo thumbnails
- Replace undefined eventId with route param id to build admin thumbnail URL
- Fixes runtime ReferenceError on /admin/events/:id/feedback when opening Feedback tab

Refs: #19
2025-09-15 22:59:21 +02:00
paul 6368f1027f feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled
- Respect requireNameEmail; prompt via identity modal before submitting feedback
- Wire feedback settings from GalleryView -> layouts via feedbackOptions

feat(lightbox): keep feedback usable while navigating

- Add initialShowFeedback prop; preserve panel state across navigation
- Offset Next button when feedback panel is open so it remains accessible
- Hide/avoid overlapping nav on small screens

Refs: #19
2025-09-15 22:59:21 +02:00
paul d64e7d08de feat(admin): refine header layout and logo placement
- Left-align logo across breakpoints; remove duplicate centered/mobile blocks
- Add date separator and spacing; keep header compact and readable

fix(admin): prevent category badge overlap in grid

- Move badge to top-left; make non-interactive; constrain width to avoid checkbox collisions

chore(docker): support ADMIN_PASSWORD in docker-compose

- Allow setting initial admin password via env for easier provisioning

chore(backend): normalize EOF newline in set-admin-password.js

Refs: admin-header-layout, category-badge-overlap, docker-admin-password
2025-09-15 22:59:21 +02:00
Gitea Actions Bot eb3751cb52 chore: bump frontend version to 1.0.125 2025-09-14 15:08:14 +00:00
paul 9fda54bd06 feat(select): add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 17:02:45 +02:00
Gitea Actions Bot 0d77a3a0a8 chore: bump version to 1.0.124 (backend + frontend) 2025-09-14 14:25:37 +00:00
paul 0618b78725 feat(setup/docker): auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 16:20:08 +02:00
paul 0178e71c67 docs: add PUID/PGID note for Docker bind mounts to avoid permission issues
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-14 16:10:46 +02:00
Gitea Actions Bot aa9b3a0227 chore: bump version to 1.0.123 (backend + frontend) 2025-09-14 13:53:37 +00:00
paul 410a33fecf feat(docker): add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example
Mirror to GitHub / mirror (push) Successful in 1m41s
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 15:46:35 +02:00
Gitea Actions Bot 05ebaaeedb chore: bump version to 1.0.122 (backend + frontend) 2025-09-09 18:54:09 +00:00
paul 84d0f63d36 feat(setup): remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:48:49 +02:00
136 changed files with 8418 additions and 2322 deletions
+6
View File
@@ -53,6 +53,12 @@ VITE_API_URL=/api
# Timezone # Timezone
TZ=UTC TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami) # Analytics (Optional - Umami)
VITE_UMAMI_URL= VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID= VITE_UMAMI_WEBSITE_ID=
+3 -5
View File
@@ -1,10 +1,7 @@
name: Mirror to GitHub name: Mirror to GitHub
on: on:
push: workflow_dispatch: # Allow manual triggering only
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs: jobs:
mirror: mirror:
@@ -131,4 +128,5 @@ jobs:
run: | run: |
echo "✅ Mirror to GitHub workflow completed successfully!" echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak" echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror" echo "🔒 Sensitive files have been removed from the mirror"
+12 -10
View File
@@ -1,12 +1,7 @@
name: Version and Release name: Version and Release
on: on:
push: workflow_dispatch:
branches: [ main ]
paths-ignore:
- '**.md'
- '.gitea/**'
- '.drone.yml'
jobs: jobs:
version-bump: version-bump:
@@ -72,8 +67,8 @@ jobs:
echo "Root files changed: $ROOT_CHANGED" echo "Root files changed: $ROOT_CHANGED"
# Get current versions # Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0") BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0") FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
echo "Current backend version: $BACKEND_VERSION" echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION" echo "Current frontend version: $FRONTEND_VERSION"
@@ -133,10 +128,17 @@ jobs:
MINOR="${version_parts[1]}" MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}" PATCH="${version_parts[2]}"
# Increment patch version # Increment patch version and ensure tag uniqueness
git fetch --tags --quiet || true
NEW_PATCH=$((PATCH + 1)) NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
NEW_PATCH=$((NEW_PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
done
echo "New version: $NEW_VERSION" echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
@@ -264,4 +266,4 @@ jobs:
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}" echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}" echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag" echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event # Drone CI will automatically trigger on the tag push event
+20 -1
View File
@@ -201,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`) - Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable) - Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env) ### Backend Configuration (.env)
Update `.env` with: Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value) - `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
@@ -411,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database. If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. **Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email #### Configuring Admin Email
+22
View File
@@ -34,6 +34,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 📧 **Automated Emails** - Creation confirmations and expiration warnings - 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement - 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 🎨 **Custom Themes** - Match your brand perfectly - 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients ### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface - 🖼️ **Beautiful Galleries** - Clean, modern interface
@@ -72,6 +73,14 @@ docker-compose up -d
# Access at http://localhost:3005 # Access at http://localhost:3005
``` ```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a nonroot user by default.
- Set `PUID` and `PGID` in your `.env` to match your host users UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
## 📖 Documentation ## 📖 Documentation
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions - 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
@@ -81,6 +90,18 @@ docker-compose up -d
- 🔒 [**Security**](SECURITY.md) - Security policies - 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines - 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## 🎯 Use Cases ## 🎯 Use Cases
Perfect for: Perfect for:
@@ -201,6 +222,7 @@ These features are currently in beta testing and may have limited functionality
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented | | **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open | | **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned | | **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned **Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
+1
View File
@@ -292,6 +292,7 @@ sudo systemctl stop picpeak-backend picpeak-workers
sudo systemctl restart picpeak-backend picpeak-workers sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak # Update PicPeak
# (reruns migrations to pick up schema fixes for native installs)
sudo ./setup.sh --update sudo ./setup.sh --update
``` ```
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine AS builder FROM node:20-alpine AS builder
# Add build arguments # Add build arguments
ARG CACHEBUST=1 ARG CACHEBUST=1
@@ -23,7 +23,7 @@ RUN npm ci --only=production
COPY . . COPY . .
# Production stage # Production stage
FROM node:18-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals'); const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
console.error('Failed to cleanup S3 objects:', error); console.error('Failed to cleanup S3 objects:', error);
} }
} }
}); });
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals'); const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs'); const mockFs = require('mock-fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
); );
}); });
}); });
}); });
+6
View File
@@ -1,4 +1,10 @@
beforeAll(() => { beforeAll(() => {
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
if (!process.env.SKIP_S3_TESTS) {
process.env.SKIP_S3_TESTS = 'true';
}
if (!process.env.STORAGE_PATH) {
process.env.STORAGE_PATH = '/storage';
}
}); });
+72 -3
View File
@@ -3,6 +3,54 @@ require('dotenv').config();
const path = require('path'); const path = require('path');
// Database configuration for different environments // Database configuration for different environments
const resolveSqliteFilename = (filenameEnv) => {
const fallback = path.join(__dirname, './data/photo_sharing.db');
if (!filenameEnv) {
return fallback;
}
const trimmed = String(filenameEnv).trim();
if (!trimmed) {
return fallback;
}
let resolved;
if (path.isAbsolute(trimmed)) {
resolved = trimmed;
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
resolved = path.resolve(__dirname, trimmed);
} else {
resolved = path.join(__dirname, trimmed);
}
const normalized = path.normalize(resolved);
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
if (normalized.includes(duplicatePattern)) {
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
}
return normalized;
};
const sqliteConnection = (filenameEnv) => ({
filename: resolveSqliteFilename(filenameEnv)
});
const baseSqliteConfig = {
client: 'sqlite3',
connection: sqliteConnection(),
useNullAsDefault: true,
migrations: {
directory: './migrations'
},
seeds: {
directory: './seeds'
}
};
const config = { const config = {
development: { development: {
client: process.env.DATABASE_CLIENT || 'sqlite3', client: process.env.DATABASE_CLIENT || 'sqlite3',
@@ -13,7 +61,7 @@ const config = {
password: process.env.DB_PASSWORD || 'postgres', password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing' database: process.env.DB_NAME || 'photo_sharing'
} : { } : {
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db') filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
}, },
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg', useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
migrations: { migrations: {
@@ -24,6 +72,26 @@ const config = {
} }
}, },
test: (() => {
const client = process.env.DATABASE_CLIENT || 'sqlite3';
const isPostgres = client === 'pg';
return {
...baseSqliteConfig,
client,
useNullAsDefault: !isPostgres,
connection: isPostgres
? {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing_test'
}
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
};
})(),
production: { production: {
client: process.env.DATABASE_CLIENT || 'pg', client: process.env.DATABASE_CLIENT || 'pg',
// Support both Postgres and SQLite in production based on DATABASE_CLIENT // Support both Postgres and SQLite in production based on DATABASE_CLIENT
@@ -42,7 +110,7 @@ const config = {
keepAliveInitialDelayMillis: 0 keepAliveInitialDelayMillis: 0
} }
: { : {
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db') filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
}, },
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg', useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg' pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
@@ -63,5 +131,6 @@ const config = {
acquireConnectionTimeout: 60000 acquireConnectionTimeout: 60000
} }
}; };
const env = process.env.NODE_ENV || 'development';
module.exports = config[process.env.NODE_ENV || 'development']; module.exports = config[env] || config.development;
@@ -0,0 +1,51 @@
const logger = require('../../src/utils/logger');
async function ensureColumn(knex, tableName, columnName, alterFn) {
const exists = await knex.schema.hasColumn(tableName, columnName);
if (!exists) {
logger.info(`Adding column ${tableName}.${columnName}`);
await knex.schema.table(tableName, alterFn);
}
}
exports.up = async function(knex) {
await ensureColumn(knex, 'events', 'host_name', (table) => {
table.string('host_name');
});
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
table.boolean('allow_user_uploads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
table.integer('upload_category_id');
});
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
table.boolean('allow_downloads').defaultTo(true);
});
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
table.boolean('disable_right_click').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
table.boolean('watermark_downloads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
table.text('watermark_text');
});
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
table.string('uploaded_by').defaultTo('admin');
});
};
exports.down = async function() {
// Non destructive migration; no rollback
};
@@ -0,0 +1,46 @@
const {
DEFAULT_PUBLIC_SITE_HTML,
} = require('../../src/constants/publicSiteDefaults');
exports.up = async function(knex) {
const defaults = [
{
setting_key: 'general_public_site_enabled',
setting_value: JSON.stringify(false),
setting_type: 'general'
},
{
setting_key: 'general_public_site_html',
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
setting_type: 'general'
},
{
setting_key: 'general_public_site_custom_css',
setting_value: JSON.stringify(''),
setting_type: 'general'
}
];
for (const setting of defaults) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = async function(knex) {
await knex('app_settings')
.whereIn('setting_key', [
'general_public_site_enabled',
'general_public_site_html',
'general_public_site_custom_css'
])
.del();
};
+4 -2
View File
@@ -120,7 +120,9 @@ async function runMigrations() {
// Check if this is a new deployment // Check if this is a new deployment
// It's new if no essential tables exist OR no migrations have been applied // It's new if no essential tables exist OR no migrations have been applied
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0; const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
// Only detect existing schema for truly existing deployments // Only detect existing schema for truly existing deployments
if (!isNewDeployment) { if (!isNewDeployment) {
@@ -227,4 +229,4 @@ if (require.main === module) {
waitAndRun(); waitAndRun();
} }
module.exports = { runMigrations }; module.exports = { runMigrations };
+212 -10
View File
@@ -1,21 +1,22 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.121", "version": "1.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.121", "version": "1.1.4",
"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",
"@aws-sdk/s3-request-presigner": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^1.10.0", "axios": "^1.12.2",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
@@ -37,6 +38,7 @@
"nodemailer": "7.0.5", "nodemailer": "7.0.5",
"pg": "^8.16.3", "pg": "^8.16.3",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3", "sharp": "0.34.3",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -46,6 +48,7 @@
"devDependencies": { "devDependencies": {
"eslint": "^8.40.0", "eslint": "^8.40.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"supertest": "^6.3.3" "supertest": "^6.3.3"
} }
@@ -3889,13 +3892,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.10.0", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -4727,6 +4730,28 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -4898,7 +4923,6 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4992,6 +5016,61 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -5107,6 +5186,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -5198,7 +5289,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -6146,6 +6236,25 @@
"void-elements": "3.1.0" "void-elements": "3.1.0"
} }
}, },
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -6540,6 +6649,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -7991,6 +8109,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mock-fs": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
"integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8015,6 +8143,24 @@
"node": ">= 10.16.0" "node": ">= 10.16.0"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -8520,6 +8666,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -8669,7 +8821,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -8764,6 +8915,34 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -9355,6 +9534,20 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -9727,6 +9920,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": { "node_modules/source-map-support": {
"version": "0.5.13", "version": "0.5.13",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.121", "version": "1.1.4",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@@ -17,9 +17,10 @@
"@aws-sdk/s3-request-presigner": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^1.10.0", "axios": "^1.12.2",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
@@ -41,6 +42,7 @@
"nodemailer": "7.0.5", "nodemailer": "7.0.5",
"pg": "^8.16.3", "pg": "^8.16.3",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3", "sharp": "0.34.3",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -50,6 +52,7 @@
"devDependencies": { "devDependencies": {
"eslint": "^8.40.0", "eslint": "^8.40.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"supertest": "^6.3.3" "supertest": "^6.3.3"
} }
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const readline = require('readline'); const readline = require('readline');
const rl = readline.createInterface({ const args = process.argv.slice(2);
const hasFlag = (flag) => args.includes(flag);
const getOption = (name) => {
const index = args.indexOf(`--${name}`);
if (index !== -1 && index + 1 < args.length) {
return args[index + 1];
}
return null;
};
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
const credentialsFileArg = getOption('credentials-file');
const resolvedCredentialsFile = credentialsFileArg
? path.resolve(process.cwd(), credentialsFileArg)
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const rl = force ? null : readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout output: process.stdout
}); });
async function question(prompt) { async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(prompt, resolve); rl.question(prompt, resolve);
}); });
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username); console.log('Found admin user:', admin.username);
console.log('Email:', admin.email); console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.'); if (!force) {
console.log('\nThis will reset the password for this admin account.');
const confirm = await question('\nDo you want to continue? (yes/no): '); }
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { const confirm = await ask('\nDo you want to continue? (yes/no): ');
console.log('\n❌ Password reset cancelled.');
process.exit(0); if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
} }
// Generate new password // Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
}); });
// Save to file // Save to file
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt'); const credentialsDir = path.dirname(resolvedCredentialsFile);
await fs.mkdir(credentialsDir, { recursive: true });
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
const resetInfo = ` const resetInfo = `
======================================== ========================================
PicPeak Admin Password Reset PicPeak Admin Credentials
======================================== ========================================
Password has been reset for admin account: Your admin account has been reset with these credentials:
Username: admin Username: ${admin.username}
New Password: ${newPassword} Email: ${admin.email}
Password: ${newPassword}
IMPORTANT: IMPORTANT SECURITY NOTES:
1. You MUST change this password on next login 1. You MUST change this password after first login
2. This file contains sensitive information 2. This file contains sensitive information
3. Delete this file after noting the password 3. Delete this file after noting the password
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin Login URL: ${adminUrl}
Reset performed on: ${new Date().toISOString()} Reset performed on: ${new Date().toISOString()}
======================================== ========================================
`; `;
await fs.writeFile(resetInfoPath, resetInfo, 'utf8'); await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
console.log('\n✅ Password reset successful!\n'); console.log('\n✅ Password reset successful!\n');
console.log('========================================'); console.log('========================================');
console.log('New Credentials:'); console.log('New Credentials:');
console.log('========================================'); console.log('========================================');
console.log('Username: admin'); console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`); console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:'); console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login'); console.log('1. You will be required to change this password on next login');
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt'); console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
console.log('3. Delete the file after noting the password'); console.log('3. Delete the file after noting the password');
console.log('========================================\n'); console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message); console.error('❌ Error resetting password:', error.message);
process.exit(1); process.exit(1);
} finally { } finally {
rl.close(); if (rl) {
rl.close();
}
process.exit(0); process.exit(0);
} }
} }
// Run the reset // Run the reset
resetAdminPassword(); resetAdminPassword();
+1 -1
View File
@@ -36,4 +36,4 @@ async function setAdminPassword() {
} }
} }
setAdminPassword(); setAdminPassword();
+203 -5
View File
@@ -26,6 +26,12 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser');
const {
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('./src/utils/tokenUtils');
// Import routes // Import routes
const authRoutes = require('./src/routes/auth-enhanced'); const authRoutes = require('./src/routes/auth-enhanced');
@@ -47,14 +53,18 @@ app.set('trust proxy', 'loopback, linklocal, uniquelocal');
const enableHsts = process.env.ENABLE_HSTS === 'true'; const enableHsts = process.env.ENABLE_HSTS === 'true';
const cspDirectives = { const cspDirectives = {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React scriptSrc: [
"'self'",
'https://www.google.com',
'https://www.gstatic.com'
],
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
connectSrc: ["'self'"], // API connections connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
fontSrc: ["'self'", "https:", "data:"], // Web fonts fontSrc: ["'self'", "https:", "data:"], // Web fonts
objectSrc: ["'none'"], // Disable plugins objectSrc: ["'none'"], // Disable plugins
mediaSrc: ["'self'"], // Audio/video mediaSrc: ["'self'"], // Audio/video
frameSrc: ["'none'"], // Disable iframes frameSrc: ["'self'", 'https://www.google.com'],
}; };
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment) // Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
if (enableHsts) { if (enableHsts) {
@@ -62,6 +72,45 @@ if (enableHsts) {
cspDirectives.upgradeInsecureRequests = []; cspDirectives.upgradeInsecureRequests = [];
} }
app.use(cookieParser());
app.use((req, res, next) => {
if (req.headers.authorization) {
return next();
}
const path = req.path || '';
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req, slug);
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
const isGalleryRequest = Boolean(slugMatch)
|| path.startsWith('/api/gallery')
|| path.startsWith('/gallery')
|| path.startsWith('/api/secure-images');
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
if (isAdminRequest) {
if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (isGalleryRequest) {
if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
} else if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
}
next();
});
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired // Avoid helmet adding defaults like upgrade-insecure-requests when not desired
@@ -122,6 +171,145 @@ app.options('/api/*', cors(corsOptions));
let generalRateLimiter; let generalRateLimiter;
let authRateLimiter; let authRateLimiter;
function composeInlineStyles(payload) {
const { branding } = payload;
const cssSegments = [];
cssSegments.push(`:root {
--brand-primary: ${branding.colors.primary};
--brand-accent: ${branding.colors.accent};
--brand-background: ${branding.colors.background};
--brand-text: ${branding.colors.text};
}`);
if (payload.baseCss) {
cssSegments.push(payload.baseCss);
}
if (payload.css) {
cssSegments.push(`/* Custom styles */\n${payload.css}`);
}
return cssSegments.join('\n\n');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
const tagline = branding.companyTagline
? `<p class="brand-tagline">${branding.companyTagline}</p>`
: '';
return `<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#features">${'Features'}</a>
<a href="#workflow">${'Workflow'}</a>
<a href="#collections">${'Collections'}</a>
<a href="#stories">${'Stories'}</a>
<a href="#contact">${'Contact'}</a>
</nav>
</div>
</header>`;
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const footerNote = branding.footerText
? `<p>${branding.footerText}</p>`
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
const supportLink = branding.supportEmail
? `<a href="mailto:${branding.supportEmail}">Support</a>`
: '';
const legalLinks = `
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
${supportLink}
`;
return `<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-links">
${legalLinks}
</div>
</div>
</footer>`;
}
function buildPublicSiteDocument(payload) {
const inlineStyles = composeInlineStyles(payload);
const header = renderBrandHeader(payload.branding);
const footer = renderBrandFooter(payload.branding);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
${header}
<main class="site-main">
${payload.html}
</main>
${footer}
</div>
</body>
</html>`;
}
async function handlePublicSiteRequest(req, res, next) {
try {
const payload = await getPublicSitePayload();
if (!payload.enabled) {
res.redirect(302, '/admin/login');
return;
}
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
res.status(304).end();
return;
}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
res.setHeader('ETag', payload.etag);
res.setHeader('Vary', 'Accept-Encoding');
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
res.status(200).send(document);
} catch (error) {
logger.error('Failed to render public site', { error: error.message });
next();
}
}
// Function to initialize rate limiters // Function to initialize rate limiters
async function initializeRateLimiters() { async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter(); generalRateLimiter = await createRateLimiter();
@@ -260,13 +448,23 @@ try {
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath)); const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
if (shouldServe) { if (shouldServe) {
logger.info(`Serving frontend from ${frontendDir}`); logger.info(`Serving frontend from ${frontendDir}`);
// Serve pre-built assets
app.use(express.static(frontendDir)); app.use(express.static(frontendDir));
// SPA fallback for non-API routes
app.get([ '/', '/admin', '/admin/*', '/gallery/*' ], (req, res) => { // Landing page handler or SPA fallback
app.get('/', handlePublicSiteRequest, (req, res) => {
res.sendFile(indexPath);
});
// SPA fallback for admin + gallery routes
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath); res.sendFile(indexPath);
}); });
} else { } else {
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir }); logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
app.get('/', handlePublicSiteRequest, (req, res) => {
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
});
} }
} catch (e) { } catch (e) {
logger.warn('Failed to enable frontend static serving', { error: e.message }); logger.warn('Failed to enable frontend static serving', { error: e.message });
@@ -0,0 +1,107 @@
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return {
db: mockDb,
logActivity: jest.fn(),
};
});
jest.mock('../utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const { db } = require('../database/db');
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const buildPublicSiteRows = (overrides = {}) => ([
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
]);
const buildBrandingRows = (overrides = {}) => ([
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
primaryColor: '#2563eb',
accentColor: '#1d4ed8',
backgroundColor: '#f8fafc',
textColor: '#0f172a'
}) }
]);
describe('publicSiteService', () => {
beforeEach(() => {
clearPublicSiteCache();
jest.clearAllMocks();
});
it('sanitizes stored HTML by stripping script tags', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.enabled).toBe(true);
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
expect(payload.html).not.toContain('<script');
expect(payload.baseCss.length).toBeGreaterThan(0);
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
});
it('sanitizes custom CSS and removes dangerous patterns', async () => {
const publicSiteRows = buildPublicSiteRows({
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
});
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.css).toContain('body { color: blue; }');
expect(payload.css).not.toContain('@import');
expect(payload.css).not.toContain('javascript:');
// Client-side util should match server sanitization expectations
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
expect(clientSanitized).not.toContain('@import');
expect(clientSanitized).not.toContain('javascript:');
});
it('injects branding tokens into the rendered payload', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
const brandingRows = buildBrandingRows({
companyName: 'Aurora Collective',
companyTagline: 'Modern photography for timeless celebrations.',
supportEmail: 'studio@aurora.co',
logoUrl: '/uploads/logos/aurora.png',
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#1d4ed8',
backgroundColor: '#fafafa',
textColor: '#171717'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.html).toContain('Aurora Collective');
expect(payload.html).toContain('Modern photography for timeless celebrations.');
expect(payload.html).toContain('studio@aurora.co');
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762');
});
});
+690
View File
@@ -0,0 +1,690 @@
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
const DEFAULT_PUBLIC_SITE_HTML = `
<section class="hero" id="welcome">
<div class="hero__inner">
<span class="hero__badge">PicPeak Showcase</span>
<h1>Share the story of {{company_name}}</h1>
<p class="hero__lead">{{company_tagline}}</p>
<div class="hero__cta">
<a href="#features" class="button button--primary">Explore Features</a>
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
</div>
<dl class="hero__stats">
<div>
<dt>Private invites</dt>
<dd>Secure links for every guest</dd>
</div>
<div>
<dt>Curated delivery</dt>
<dd>Highlight every favourite instantly</dd>
</div>
<div>
<dt>Fully branded</dt>
<dd>Colours, typography, and logo that match you</dd>
</div>
</dl>
</div>
<div class="hero__visual">
<article class="deck deck--primary">
<header class="deck__header">
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
<span class="deck__title">PicPeak Gallery</span>
</header>
<ul class="deck__list">
<li>Guided cover stories</li>
<li>Guest uploads with approvals</li>
<li>Protected high-res downloads</li>
</ul>
</article>
<article class="deck deck--secondary">
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
<p class="deck__author">— Studio Miraval</p>
</article>
</div>
</section>
<section class="features" id="features">
<div class="section-head">
<span class="section-badge">Why teams pick PicPeak</span>
<h2>Design-first galleries with the workflow you already love</h2>
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
</div>
<div class="feature-grid">
<article>
<h3>Beautiful by default</h3>
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
</article>
<article>
<h3>Guided storytelling</h3>
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
</article>
<article>
<h3>Secure sharing</h3>
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
</article>
</div>
</section>
<section class="workflow" id="workflow">
<div class="workflow__content">
<h2>Launch in minutes</h2>
<ol class="workflow__steps">
<li>
<h4>Brand it once</h4>
<p>PicPeak automatically applies your logo, colours, and support details.</p>
</li>
<li>
<h4>Curate sections</h4>
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
</li>
<li>
<h4>Share confidently</h4>
<p>Send a single link that greets guests before they enter their private gallery.</p>
</li>
</ol>
</div>
<div class="workflow__media">
<figure class="workflow__browser">
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
<figcaption>PicPeak dashboard &mdash; trusted by studios worldwide.</figcaption>
</figure>
</div>
</section>
<section class="collections" id="collections">
<div class="section-head">
<span class="section-badge">Showcase highlights</span>
<h2>Curated sample galleries that mirror your client experience</h2>
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
</div>
<div class="collection-showcase">
<article>
<h3>Signature Galleries</h3>
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
</article>
<article>
<h3>Welcome timelines</h3>
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
</article>
</div>
</section>
<section class="stories" id="stories">
<div class="section-head section-head--center">
<span class="section-badge">Client notes</span>
<h2>Experiences that keep guests coming back</h2>
</div>
<div class="story-grid">
<figure>
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
<figcaption>— Harper &amp; Elias</figcaption>
</figure>
<figure>
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
<figcaption>— Jordan Rivera, Photographer</figcaption>
</figure>
</div>
</section>
<section class="cta" id="contact">
<div class="cta__inner">
<div>
<h2>Ready to welcome your guests?</h2>
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
</div>
<div class="cta__actions">
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
<a href="#features" class="button button--ghost">Review features</a>
</div>
</div>
</section>
<footer class="site-footer" id="legal">
<div class="footer-inner">
<div>
<h2>{{company_name}}</h2>
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
</div>
<div class="footer-links">
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
<a href="mailto:{{support_email}}">Support</a>
</div>
</div>
</footer>
`;
const DEFAULT_PUBLIC_SITE_CSS = `
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
color: var(--brand-text);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.site-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
}
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: contain;
background: rgba(148, 163, 184, 0.12);
padding: 6px;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand-label {
margin: 0;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--brand-text);
}
.brand-tagline {
margin: 0;
font-size: 0.85rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav {
display: flex;
gap: 1rem;
font-size: 0.95rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav a {
position: relative;
padding: 0.25rem 0;
}
.site-nav a::after {
content: '';
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
background: transparent;
transition: background 0.2s ease;
}
.site-nav a:hover::after {
background: var(--brand-primary);
}
.site-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rem;
padding: 2.5rem 1.5rem 4rem;
}
@media (min-width: 960px) {
.site-main {
padding: 3rem 0 5rem;
gap: 5rem;
}
.hero,
.features,
.workflow,
.collections,
.stories,
.cta {
max-width: 1100px;
margin: 0 auto;
}
}
.hero {
display: grid;
gap: 2.5rem;
align-items: center;
}
@media (min-width: 960px) {
.hero {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
}
.hero__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.hero__badge {
display: inline-flex;
align-items: center;
padding: 0.55rem 0.9rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.18);
color: var(--brand-primary);
}
.hero h1 {
margin: 0;
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
letter-spacing: -0.02em;
line-height: 1.08;
}
.hero__lead {
margin: 0;
max-width: 32rem;
color: rgba(15, 23, 42, 0.72);
font-size: 1.05rem;
line-height: 1.6;
}
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.hero__stats {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
margin: 0;
padding: 0;
}
.hero__stats dt {
font-weight: 600;
color: var(--brand-text);
}
.hero__stats dd {
margin: 0.35rem 0 0;
color: rgba(15, 23, 42, 0.6);
font-size: 0.95rem;
}
.hero__visual {
display: grid;
gap: 1.5rem;
}
.deck {
border-radius: 20px;
padding: 1.75rem;
background: #fff;
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
display: grid;
gap: 1.35rem;
}
.deck--primary {
border-color: rgba(34, 197, 94, 0.2);
}
.deck--secondary {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
}
.deck__header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.deck__logo {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(34, 197, 94, 0.12);
padding: 6px;
}
.deck__title {
font-weight: 600;
letter-spacing: -0.01em;
}
.deck__list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.65rem;
color: rgba(15, 23, 42, 0.68);
}
.deck__quote {
margin: 0;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.78);
}
.deck__author {
margin: 0;
font-weight: 600;
color: var(--brand-text);
}
.section-head {
display: grid;
gap: 1rem;
max-width: 640px;
}
.section-head--center {
text-align: center;
margin: 0 auto;
}
.section-badge {
display: inline-flex;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.14);
color: var(--brand-primary);
}
.section-head h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.6rem);
letter-spacing: -0.018em;
}
.section-head p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.feature-grid {
display: grid;
gap: 1.5rem;
margin-top: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.feature-grid article {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
}
.workflow {
display: grid;
gap: 2rem;
align-items: center;
}
@media (min-width: 960px) {
.workflow {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
.workflow__steps {
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 1.5rem;
}
.workflow__steps h4 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
color: var(--brand-text);
}
.workflow__steps p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.workflow__browser {
margin: 0;
background: rgba(15, 23, 42, 0.05);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.1);
padding: 2rem;
text-align: center;
color: rgba(15, 23, 42, 0.55);
font-size: 0.85rem;
}
.collection-showcase {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.collection-showcase article {
background: rgba(255, 255, 255, 0.92);
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 1.5rem;
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
}
.story-grid {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.story-grid figure {
margin: 0;
padding: 1.75rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
}
.story-grid blockquote {
margin: 0 0 1.2rem;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.8);
}
.story-grid figcaption {
font-weight: 600;
color: rgba(15, 23, 42, 0.7);
}
.cta {
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
color: #fff;
border-radius: 28px;
padding: clamp(2.5rem, 5vw, 3.5rem);
}
.cta__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 720px;
}
.cta__inner h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.5rem);
}
.cta__inner p {
margin: 0;
font-size: 1.05rem;
opacity: 0.95;
}
.cta__actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1.75rem;
border-radius: 999px;
font-weight: 600;
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
border: 1px solid transparent;
}
.button:hover {
transform: translateY(-2px);
}
.button--primary {
background: var(--brand-primary);
color: #fff;
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
}
.button--primary:hover {
background: var(--brand-accent);
}
.button--ghost {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.45);
color: inherit;
}
.site-footer {
padding: 3rem 1.5rem;
background: rgba(15, 23, 42, 0.05);
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.footer-inner h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.footer-inner p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
line-height: 1.6;
}
.footer-links {
display: flex;
flex-direction: column;
gap: 0.65rem;
font-weight: 600;
color: var(--brand-primary);
}
.footer-links a {
color: inherit;
}
.footer-links a:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.site-nav {
display: none;
}
.hero__visual {
grid-template-columns: minmax(0, 1fr);
}
.workflow {
grid-template-columns: minmax(0, 1fr);
}
.cta__inner {
gap: 1.5rem;
}
}
`;
module.exports = {
DEFAULT_PUBLIC_SITE_TITLE,
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
};
+151 -2
View File
@@ -75,6 +75,13 @@ async function initializeDatabase() {
table.boolean('is_archived').defaultTo(false); table.boolean('is_archived').defaultTo(false);
table.string('archive_path'); table.string('archive_path');
table.datetime('archived_at'); table.datetime('archived_at');
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id');
table.boolean('allow_downloads').defaultTo(true);
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
}); });
} else { } else {
// Check if color_theme needs to be updated to TEXT type // Check if color_theme needs to be updated to TEXT type
@@ -104,11 +111,39 @@ async function initializeDatabase() {
archive_path TEXT, archive_path TEXT,
archived_at DATETIME, archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0, allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER upload_category_id INTEGER,
allow_downloads BOOLEAN DEFAULT 1,
disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT,
hero_photo_id INTEGER
) )
`); `);
await db.raw('INSERT INTO events_new SELECT * FROM events'); const pragmaRows = await db.raw("PRAGMA table_info('events')");
const existingColumns = pragmaRows.map(row => row.name);
const selectColumns = existingColumns.map((col) => {
switch (col) {
case 'allow_user_uploads':
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
case 'upload_category_id':
return "upload_category_id";
case 'allow_downloads':
return "COALESCE(allow_downloads, 1) as allow_downloads";
case 'disable_right_click':
return "COALESCE(disable_right_click, 0) as disable_right_click";
case 'watermark_downloads':
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
case 'watermark_text':
return 'watermark_text';
case 'hero_photo_id':
return 'hero_photo_id';
default:
return col;
}
});
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
await db.raw('DROP TABLE events'); await db.raw('DROP TABLE events');
await db.raw('ALTER TABLE events_new RENAME TO events'); await db.raw('ALTER TABLE events_new RENAME TO events');
} catch (error) { } catch (error) {
@@ -129,6 +164,7 @@ async function initializeDatabase() {
table.string('thumbnail_path'); table.string('thumbnail_path');
table.string('type').notNullable(); // 'collage' or 'individual' table.string('type').notNullable(); // 'collage' or 'individual'
table.integer('size_bytes'); table.integer('size_bytes');
table.string('uploaded_by').defaultTo('admin');
table.datetime('uploaded_at').defaultTo(db.fn.now()); table.datetime('uploaded_at').defaultTo(db.fn.now());
table.integer('view_count').defaultTo(0); table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0); table.integer('download_count').defaultTo(0);
@@ -159,11 +195,24 @@ async function initializeDatabase() {
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete' table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
table.json('email_data'); table.json('email_data');
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed' table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('scheduled_at').defaultTo(db.fn.now()); table.datetime('scheduled_at').defaultTo(db.fn.now());
table.datetime('sent_at'); table.datetime('sent_at');
table.text('error_message'); table.text('error_message');
table.integer('retry_count').defaultTo(0); table.integer('retry_count').defaultTo(0);
}); });
} else {
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
if (!hasCreatedAt) {
await db.schema.alterTable('email_queue', (table) => {
table.datetime('created_at').defaultTo(db.fn.now());
});
try {
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
} catch (updateError) {
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
}
}
} }
// Admin users table // Admin users table
@@ -181,6 +230,7 @@ async function initializeDatabase() {
table.datetime('updated_at').defaultTo(db.fn.now()); table.datetime('updated_at').defaultTo(db.fn.now());
table.datetime('last_login'); table.datetime('last_login');
table.string('last_login_ip'); table.string('last_login_ip');
table.string('language', 2).defaultTo('en');
}); });
} else { } else {
// Check if updated_at column exists // Check if updated_at column exists
@@ -216,6 +266,13 @@ async function initializeDatabase() {
table.string('last_login_ip'); table.string('last_login_ip');
}); });
} }
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
if (!hasLanguage) {
await db.schema.table('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
}
} }
// Token revocation tables // Token revocation tables
@@ -291,6 +348,18 @@ async function initializeDatabase() {
table.datetime('updated_at').defaultTo(db.fn.now()); table.datetime('updated_at').defaultTo(db.fn.now());
}); });
} }
const defaultLanguageSetting = await db('app_settings')
.where('setting_key', 'default_language')
.first();
if (!defaultLanguageSetting) {
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date(),
});
}
// Activity logs table // Activity logs table
const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
@@ -315,6 +384,86 @@ async function initializeDatabase() {
}); });
} }
} }
await ensureGlobalCategories();
}
// Ensure photo categories exist for new deployments
async function ensureGlobalCategories() {
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
if (!hasPhotoCategoriesTable) {
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
table.unique(['slug', 'event_id']);
});
}
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
if (!hasCategoryIdColumn) {
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
}
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (!hasCmsPagesTable) {
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
if (categoryCount === 0) {
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true },
];
await db('photo_categories').insert(defaultCategories);
}
const cmsPages = await db('cms_pages').select('slug');
const existingSlugs = cmsPages.map((page) => page.slug);
const defaultPages = [
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
if (!existingSlugs.includes(page.slug)) {
await db('cms_pages').insert(page);
}
}
} }
// Helper function to log activities // Helper function to log activities
+5 -3
View File
@@ -3,13 +3,14 @@ const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation'); const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/** /**
* Enhanced admin authentication middleware with revocation checking * Enhanced admin authentication middleware with revocation checking
*/ */
async function adminAuth(req, res, next) { async function adminAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const token = getAdminTokenFromRequest(req);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -97,7 +98,8 @@ async function adminAuth(req, res, next) {
*/ */
async function galleryAuth(req, res, next) { async function galleryAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -164,4 +166,4 @@ module.exports = {
adminAuth, adminAuth,
galleryAuth, galleryAuth,
// ... other exports // ... other exports
}; };
+7 -4
View File
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/** /**
* Enhanced admin authentication middleware * Enhanced admin authentication middleware
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
*/ */
async function adminAuth(req, res, next) { async function adminAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const token = getAdminTokenFromRequest(req);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -89,7 +90,8 @@ async function adminAuth(req, res, next) {
*/ */
async function galleryAuth(req, res, next) { async function galleryAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -151,7 +153,8 @@ async function galleryAuth(req, res, next) {
*/ */
async function photoAuth(req, res, next) { async function photoAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) { if (!token) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
@@ -235,4 +238,4 @@ module.exports = {
galleryAuth, galleryAuth,
photoAuth, photoAuth,
verifyGalleryAccess verifyGalleryAccess
}; };
+2 -1
View File
@@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
async function adminAuth(req, res, next) { async function adminAuth(req, res, next) {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const token = getAdminTokenFromRequest(req);
if (!token) { if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] || req.headers['x-real-ip'] ||
+5 -7
View File
@@ -1,17 +1,17 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
// Middleware to verify gallery access // Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) { async function verifyGalleryAccess(req, res, next) {
try { try {
const authHeader = req.headers.authorization; const requestedSlug = req.params.slug || req.requestedSlug;
const token = authHeader?.split(' ')[1]; const token = getGalleryTokenFromRequest(req, requestedSlug);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
// Try to verify with issuer first, fallback to no issuer for backward compatibility // Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded; let decoded;
try { try {
@@ -29,8 +29,6 @@ async function verifyGalleryAccess(req, res, next) {
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId); console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
// If we have a slug in the URL params or from pre-middleware, verify it matches // If we have a slug in the URL params or from pre-middleware, verify it matches
const requestedSlug = req.params.slug || req.requestedSlug;
let event; let event;
if (requestedSlug) { if (requestedSlug) {
// Verify by slug and ensure it matches the token's event // Verify by slug and ensure it matches the token's event
@@ -84,10 +82,10 @@ async function verifyGalleryAccess(req, res, next) {
next(); next();
} catch (error) { } catch (error) {
console.error('Error verifying gallery access:', error); console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message }); res.status(401).json({ error: 'Invalid token' });
} }
} }
module.exports = { module.exports = {
verifyGalleryAccess verifyGalleryAccess
}; };
+5 -4
View File
@@ -2,6 +2,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
async function photoAuth(req, res, next) { async function photoAuth(req, res, next) {
try { try {
@@ -20,9 +21,9 @@ async function photoAuth(req, res, next) {
} }
// First check for JWT token (from gallery access) // First check for JWT token (from gallery access)
const authHeader = req.headers.authorization; const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
if (authHeader && authHeader.startsWith('Bearer ')) { if (tokenFromRequest) {
const token = authHeader.replace('Bearer ', ''); const token = tokenFromRequest;
try { try {
// Try to verify with issuer first, fallback to no issuer for backward compatibility // Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded; let decoded;
@@ -88,7 +89,7 @@ async function photoAuth(req, res, next) {
// Check for password header (legacy support) // Check for password header (legacy support)
const password = req.headers['x-gallery-password']; const password = req.headers['x-gallery-password'];
if (!password && !authHeader) { if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
+3 -6
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
// In-memory session tracking (in production, use Redis) // In-memory session tracking (in production, use Redis)
const sessions = new Map(); const sessions = new Map();
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
async function sessionTimeoutMiddleware(req, res, next) { async function sessionTimeoutMiddleware(req, res, next) {
// Skip for non-authenticated routes // Skip for non-authenticated routes
if (!req.headers.authorization) { const token = getAdminTokenFromRequest(req);
return next();
}
const token = req.headers.authorization.split(' ')[1];
if (!token) { if (!token) {
return next(); return next();
} }
@@ -150,4 +147,4 @@ module.exports = {
sessionTimeoutMiddleware, sessionTimeoutMiddleware,
endSession, endSession,
getActiveSessions getActiveSessions
}; };
+25 -16
View File
@@ -221,7 +221,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
await fs.access(config.path, fs.constants.W_OK); await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' }); res.json({ success: true, message: 'Local path is writable' });
} catch (error) { } catch (error) {
res.json({ success: false, message: 'Cannot write to local path: ' + error.message }); logger.warn('Local backup path not writable', {
path: config.path,
error: error.message
});
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
} }
break; break;
@@ -243,7 +247,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
const { stdout } = await execAsync(testCommand); const { stdout } = await execAsync(testCommand);
res.json({ success: true, message: 'Rsync connection successful' }); res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) { } catch (error) {
res.json({ success: false, message: 'Rsync connection failed: ' + error.message }); logger.warn('Rsync connection test failed', {
destination: config.host || config.destination,
error: error.message
});
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
} }
break; break;
@@ -274,7 +282,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get backup manifest:', error); logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' }); res.status(404).json({ error: 'Backup manifest not found' });
} }
}); });
@@ -327,7 +335,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to download backup manifest:', error); logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' }); res.status(404).json({ error: 'Backup manifest not found' });
} }
}); });
@@ -344,7 +352,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get backup manifest:', error); logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' }); res.status(404).json({ error: 'Backup manifest not found' });
} }
}); });
@@ -375,7 +383,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to download backup manifest:', error); logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' }); res.status(404).json({ error: 'Backup manifest not found' });
} }
}); });
@@ -436,7 +444,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to list S3 buckets:', error); logger.error('Failed to list S3 buckets:', error);
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message }); res.status(500).json({ error: 'Failed to list S3 buckets' });
} }
}); });
@@ -473,7 +481,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to list S3 files:', error); logger.error('Failed to list S3 files:', error);
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message }); res.status(500).json({ error: 'Failed to list S3 files' });
} }
}); });
@@ -535,7 +543,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to cleanup S3 backups:', error); logger.error('Failed to cleanup S3 backups:', error);
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message }); res.status(500).json({ error: 'Failed to cleanup S3 backups' });
} }
}); });
@@ -587,7 +595,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('S3 upload test failed:', error); logger.error('S3 upload test failed:', error);
res.status(500).json({ error: 'S3 upload test failed: ' + error.message }); res.status(500).json({ error: 'S3 upload test failed' });
} }
}); });
@@ -675,7 +683,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to download backup:', error); logger.error('Failed to download backup:', error);
res.status(500).json({ error: 'Failed to download backup: ' + error.message }); res.status(500).json({ error: 'Failed to download backup' });
} }
}); });
@@ -744,7 +752,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get file checksums:', error); logger.error('Failed to get file checksums:', error);
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message }); res.status(500).json({ error: 'Failed to get file checksums' });
} }
}); });
@@ -847,7 +855,7 @@ router.post('/estimate', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
logger.error('Failed to estimate backup size:', error); logger.error('Failed to estimate backup size:', error);
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message }); res.status(500).json({ error: 'Failed to estimate backup size' });
} }
}); });
@@ -945,12 +953,13 @@ async function validateManifestData(manifestData) {
} }
}; };
} catch (error) { } catch (error) {
logger.error('Manifest validation error', { error: error.message });
return { return {
valid: false, valid: false,
error: `Validation error: ${error.message}`, error: 'Validation error encountered while processing manifest',
details: { error: error.message } details: { hint: 'See server logs for diagnostic details.' }
}; };
} }
} }
module.exports = router; module.exports = router;
+25 -8
View File
@@ -396,7 +396,9 @@ router.put('/:id', adminAuth, [
body('allow_downloads').optional().isBoolean(), body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(), body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim() body('watermark_text').optional().trim(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -407,7 +409,24 @@ router.put('/:id', adminAuth, [
} }
const { id } = req.params; const { id } = req.params;
const updates = req.body; const updates = { ...req.body };
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
}
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
updates.external_path = trimmedPath || null;
}
if (updates.source_mode === 'managed') {
updates.external_path = null;
}
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Log the update request for debugging // Log the update request for debugging
console.log('Update event request:', { console.log('Update event request:', {
@@ -515,13 +534,11 @@ router.delete('/:id', adminAuth, async (req, res) => {
// Provide more specific error messages // Provide more specific error messages
if (error.message && error.message.includes('foreign key constraint')) { if (error.message && error.message.includes('foreign key constraint')) {
res.status(500).json({ res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.', error: 'Cannot delete event due to existing references. Please contact support.'
details: error.message
}); });
} else { } else {
res.status(500).json({ res.status(500).json({
error: 'Failed to delete event', error: 'Failed to delete event'
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}); });
} }
} }
@@ -780,7 +797,7 @@ router.post('/bulk-archive', adminAuth, [
results.failed.push({ results.failed.push({
id: event.id, id: event.id,
name: event.event_name, name: event.event_name,
error: error.message error: 'Failed to archive event. Check server logs for details.'
}); });
} }
} }
@@ -806,4 +823,4 @@ router.post('/bulk-archive', adminAuth, [
} }
}); });
module.exports = router; module.exports = router;
+41 -6
View File
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
@@ -14,7 +15,11 @@ router.get('/list', adminAuth, async (req, res) => {
const result = await list(relPath); const result = await list(relPath);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
res.status(400).json({ error: 'Invalid path', details: error.message }); logger.warn('Invalid external media path requested', {
path: req.query.path,
error: error.message
});
res.status(400).json({ error: 'Invalid external media path' });
} }
}); });
@@ -57,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name })) .map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase())); .filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
let imported = 0; // Prepare file metadata and deduplicate by filename within type (keep largest)
let skipped = 0; let skipped = 0;
const preparedFiles = [];
for (const f of files) {
try {
const stats = await fs.stat(f.full);
const segs = f.rel.split(path.sep);
let type = 'individual';
if (segs[0] === map.collages) type = 'collage';
if (segs[0] === map.individual) type = 'individual';
preparedFiles.push({ ...f, type, size: stats.size });
} catch (err) {
skipped++;
}
}
const dedupeMap = new Map();
for (const file of preparedFiles) {
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
const existing = dedupeMap.get(dedupeKey);
if (!existing || file.size > existing.size) {
if (existing) skipped++;
dedupeMap.set(dedupeKey, file);
} else {
skipped++;
}
}
let imported = 0;
// Insert photos // Insert photos
for (const f of files) { for (const f of dedupeMap.values()) {
// Infer type by subfolder names // Infer type by subfolder names
const segs = f.rel.split(path.sep); const segs = f.rel.split(path.sep);
let type = 'individual'; let type = 'individual';
@@ -74,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
.where({ event_id: eventId, external_relpath: f.rel }) .where({ event_id: eventId, external_relpath: f.rel })
.first(); .first();
if (exists) { skipped++; continue; } if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full); const stats = await fs.stat(f.full);
const inserted = await db('photos') const inserted = await db('photos')
.insert({ .insert({
@@ -104,9 +135,13 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
res.json({ imported, skipped, thumbnailsQueued: 0 }); res.json({ imported, skipped, thumbnailsQueued: 0 });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to import external media', details: error.message }); logger.error('External media import failed', {
eventId: req.params.id,
externalPath: req.body?.external_path,
error: error.message
});
res.status(500).json({ error: 'Failed to import external media' });
} }
}); });
module.exports = router; module.exports = router;
+2 -2
View File
@@ -368,7 +368,7 @@ router.post('/word-filters',
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
if (error.message === 'Word filter already exists') { if (error.message === 'Word filter already exists') {
return res.status(409).json({ error: error.message }); return res.status(409).json({ error: 'Word filter already exists' });
} }
logger.error('Error adding word filter:', error); logger.error('Error adding word filter:', error);
res.status(500).json({ error: 'Failed to add word filter' }); res.status(500).json({ error: 'Failed to add word filter' });
@@ -430,4 +430,4 @@ function convertToCSV(data) {
return [csvHeaders, ...csvRows].join('\n'); return [csvHeaders, ...csvRows].join('\n');
} }
module.exports = router; module.exports = router;
+2 -1
View File
@@ -802,7 +802,8 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
storagePath: getStoragePath() storagePath: getStoragePath()
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); console.error('Error fetching admin photo debug data:', error);
res.status(500).json({ error: 'Failed to fetch photo debug data' });
} }
}); });
+3 -3
View File
@@ -82,7 +82,7 @@ router.post('/validate', [
logger.error('Restore validation failed:', error); logger.error('Restore validation failed:', error);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message, error: 'Restore validation failed',
logs: restoreService.restoreLog logs: restoreService.restoreLog
}); });
} }
@@ -162,7 +162,7 @@ router.post('/start', [
logger.error('Failed to start restore:', error); logger.error('Failed to start restore:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message error: 'Failed to start restore operation'
}); });
} }
}); });
@@ -458,4 +458,4 @@ async function getBackupConfig() {
return config; return config;
} }
module.exports = router; module.exports = router;
+308 -4
View File
@@ -8,6 +8,16 @@ const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService'); const { clearSettingsCache } = require('../services/rateLimitService');
const {
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
} = require('../constants/publicSiteDefaults');
const {
clearPublicSiteCache,
getDefaultPublicSitePayload,
getRawPublicSiteSettings,
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const router = express.Router(); const router = express.Router();
// Configure multer for logo uploads // Configure multer for logo uploads
@@ -284,6 +294,8 @@ router.put('/branding', adminAuth, async (req, res) => {
metadata: JSON.stringify({ company_name }) metadata: JSON.stringify({ company_name })
}); });
clearPublicSiteCache();
res.json({ message: 'Branding settings updated successfully' }); res.json({ message: 'Branding settings updated successfully' });
} catch (error) { } catch (error) {
console.error('Branding update error:', error); console.error('Branding update error:', error);
@@ -443,6 +455,8 @@ router.put('/theme', adminAuth, async (req, res) => {
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' }) metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
}); });
clearPublicSiteCache();
res.json({ message: 'Theme settings updated successfully' }); res.json({ message: 'Theme settings updated successfully' });
} catch (error) { } catch (error) {
console.error('Theme update error:', error); console.error('Theme update error:', error);
@@ -453,7 +467,39 @@ router.put('/theme', adminAuth, async (req, res) => {
// Update general settings // Update general settings
router.put('/general', adminAuth, async (req, res) => { router.put('/general', adminAuth, async (req, res) => {
try { try {
const settings = req.body; const settings = { ...req.body };
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
settings.general_public_site_html = settings.general_public_site_html.trim();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
}
const enableToggle = settings.general_public_site_enabled;
if (enableToggle === true) {
let htmlValue = settings.general_public_site_html;
if (htmlValue === undefined) {
const currentSettings = await getRawPublicSiteSettings();
htmlValue = currentSettings.general_public_site_html;
}
if (!htmlValue || !String(htmlValue).trim()) {
return res.status(400).json({
error: 'Public site HTML must be provided before enabling the public landing page.'
});
}
}
}
// Update or insert each setting // Update or insert each setting
for (const [key, value] of Object.entries(settings)) { for (const [key, value] of Object.entries(settings)) {
@@ -476,6 +522,10 @@ router.put('/general', adminAuth, async (req, res) => {
clearMaintenanceCache(); clearMaintenanceCache();
} }
if (publicSiteKeysTouched) {
clearPublicSiteCache();
}
// Log activity // Log activity
await db('activity_logs').insert({ await db('activity_logs').insert({
activity_type: 'general_settings_updated', activity_type: 'general_settings_updated',
@@ -603,11 +653,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
} }
} }
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
let diskStats = null;
let rawDiskTotal = null;
let rawDiskFree = null;
let rawDiskAvailable = null;
try {
diskStats = await fs.statfs(storagePath);
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
} catch (diskError) {
console.error('Disk stats error:', diskError.message);
}
const clampDiskValue = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
// Treat unusually large virtualised values as unreliable (>50TB)
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
if (value > MAX_REASONABLE_BYTES) {
return null;
}
return value;
};
let diskTotal = null;
let diskFree = null;
let diskAvailable = null;
if (diskStats) {
diskTotal = clampDiskValue(rawDiskTotal);
diskFree = clampDiskValue(rawDiskFree);
diskAvailable = clampDiskValue(rawDiskAvailable);
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
diskAvailable = null;
}
if (diskTotal && diskFree && diskFree > diskTotal) {
diskFree = null;
}
}
const totalUsed = totalStorage?.total || 0;
const parseBytesValue = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
return Math.floor(numeric);
};
const parseEnvOverride = (bytesVar, gbVar) => {
if (process.env[bytesVar]) {
return parseBytesValue(process.env[bytesVar]);
}
if (process.env[gbVar]) {
const value = parseBytesValue(process.env[gbVar]);
return value ? value * 1024 * 1024 * 1024 : null;
}
return null;
};
let configuredSoftLimit = null;
let capacityOverrideDb = null;
let availableOverrideDb = null;
try {
const storageSettings = await db('app_settings')
.whereIn('setting_key', [
'general_storage_soft_limit_bytes',
'general_storage_capacity_override_bytes',
'general_storage_available_override_bytes'
])
.select('setting_key', 'setting_value');
storageSettings.forEach((setting) => {
let parsedValue = null;
if (setting.setting_value) {
try {
parsedValue = JSON.parse(setting.setting_value);
} catch (error) {
parsedValue = setting.setting_value;
}
}
switch (setting.setting_key) {
case 'general_storage_soft_limit_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
configuredSoftLimit = parsedValue;
}
break;
case 'general_storage_capacity_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
capacityOverrideDb = parsedValue;
}
break;
case 'general_storage_available_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
availableOverrideDb = parsedValue;
}
break;
default:
break;
}
});
} catch (error) {
console.error('Storage settings read error:', error.message);
}
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
let capacityOverrideBytes = null;
let availableOverrideBytes = null;
let overrideSource = null;
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
capacityOverrideBytes = capacityOverrideEnv;
availableOverrideBytes = availableOverrideEnv;
overrideSource = 'env';
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
capacityOverrideBytes = capacityOverrideDb;
availableOverrideBytes = availableOverrideDb;
overrideSource = 'settings';
}
if (capacityOverrideBytes != null) {
diskTotal = capacityOverrideBytes;
if (availableOverrideBytes == null) {
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
} else {
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
}
diskFree = diskAvailable;
} else if (availableOverrideBytes != null) {
diskAvailable = Math.max(availableOverrideBytes, 0);
diskFree = diskAvailable;
}
let recommendedSoftLimit = null;
if (diskTotal && diskAvailable) {
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
} else if (diskTotal) {
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
} else if (diskAvailable) {
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
}
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
recommendedSoftLimit = totalUsed;
}
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
if (!recommendedSoftLimit && fallbackSoftLimit) {
recommendedSoftLimit = fallbackSoftLimit;
}
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
const diskMetricsReliable = Boolean(diskTotal);
res.json({ res.json({
total_used: totalStorage.total || 0, total_used: totalUsed,
archive_storage: archiveStorage, archive_storage: archiveStorage,
storage_by_event: storageByEvent, storage_by_event: storageByEvent,
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default storage_limit: effectiveSoftLimit,
storage_soft_limit: effectiveSoftLimit,
configured_soft_limit: configuredSoftLimit,
recommended_soft_limit: recommendedSoftLimit,
soft_limit_configured: Boolean(configuredSoftLimit),
disk_total: diskTotal,
disk_free: diskFree,
disk_available: diskAvailable,
disk_total_raw: rawDiskTotal,
disk_free_raw: rawDiskFree,
disk_available_raw: rawDiskAvailable,
disk_metrics_reliable: diskMetricsReliable,
disk_override_source: overrideSource
}); });
} catch (error) { } catch (error) {
console.error('Storage info error:', error); console.error('Storage info error:', error);
@@ -717,4 +946,79 @@ router.put('/security/rate-limit', adminAuth, [
} }
}); });
module.exports = router; // Get default public site template
router.get('/public-site/default', adminAuth, async (req, res) => {
try {
const defaults = await getDefaultPublicSitePayload();
res.json({
enabled: false,
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
css: '',
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
branding: defaults.branding,
meta: {
title: defaults.title,
}
});
} catch (error) {
console.error('Failed to load public site defaults:', error);
res.status(500).json({ error: 'Failed to load defaults' });
}
});
// Reset public site template to defaults
router.post('/public-site/reset', adminAuth, async (req, res) => {
try {
const entries = [
{
key: 'general_public_site_html',
value: DEFAULT_PUBLIC_SITE_HTML.trim()
},
{
key: 'general_public_site_custom_css',
value: ''
}
];
for (const { key, value } of entries) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'general',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
clearPublicSiteCache();
const defaults = await getDefaultPublicSitePayload();
await logActivity('public_site_reset_to_default',
{
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Public site template reset to defaults',
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
css: '',
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
branding: defaults.branding
});
} catch (error) {
console.error('Failed to reset public site template:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
module.exports = router;
+7 -2
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
// Get system version // Get system version
@@ -208,10 +209,14 @@ router.get('/database', adminAuth, async (req, res) => {
}); });
} catch (error) { } catch (error) {
// Table might not exist // Table might not exist
logger.warn('Failed to retrieve table info', {
table,
error: error.message
});
tableInfo.push({ tableInfo.push({
name: table, name: table,
rows: 0, rows: 0,
error: error.message error: 'Unable to retrieve table details'
}); });
} }
} }
@@ -226,4 +231,4 @@ router.get('/database', adminAuth, async (req, res) => {
} }
}); });
module.exports = router; module.exports = router;
+116 -10
View File
@@ -14,6 +14,14 @@ const {
} = require('../utils/authSecurity'); } = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout'); const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const router = express.Router(); const router = express.Router();
// Admin login with enhanced security // Admin login with enhanced security
@@ -91,6 +99,8 @@ router.post('/admin/login', [
expiresIn: '24h', expiresIn: '24h',
issuer: 'picpeak-auth' issuer: 'picpeak-auth'
}); });
setAdminAuthCookie(res, token);
res.json({ res.json({
token, token,
@@ -110,13 +120,14 @@ router.post('/admin/login', [
// Logout endpoint // Logout endpoint
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req);
const token = adminToken || galleryToken;
if (token) { if (token) {
// End the session // End the session
endSession(token); endSession(token);
// Log the logout
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', { logger.info('User logged out', {
@@ -124,11 +135,23 @@ router.post('/logout', async (req, res) => {
username: decoded.username, username: decoded.username,
type: decoded.type type: decoded.type
}); });
if (decoded.type === 'admin') {
clearAdminAuthCookie(res);
} else if (decoded.type === 'gallery') {
clearGalleryAuthCookies(res, decoded.eventSlug);
}
} catch (err) { } catch (err) {
// Token might be invalid, but still process logout // Token might be invalid, but still process logout and clear cookies
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
} }
} else {
// No token found, but ensure cookies are cleared
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
} }
res.json({ message: 'Logged out successfully' }); res.json({ message: 'Logged out successfully' });
} catch (error) { } catch (error) {
logger.error('Logout error:', error); logger.error('Logout error:', error);
@@ -209,6 +232,8 @@ router.post('/gallery/verify', [
expiresIn: '24h', expiresIn: '24h',
issuer: 'picpeak-auth' issuer: 'picpeak-auth'
}); });
setGalleryAuthCookies(res, token, event.slug);
res.json({ res.json({
token, token,
@@ -230,15 +255,94 @@ router.post('/gallery/verify', [
} }
}); });
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
body('token').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, token } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
let expectedToken = event.share_link;
if (expectedToken && expectedToken.includes('/')) {
expectedToken = expectedToken.split('/').pop();
}
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
res.json({
token: jwtToken,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
}
});
// Gallery logout to clear cookies
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current session info // Get current session info
router.get('/session', async (req, res) => { router.get('/session', async (req, res) => {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
@@ -250,7 +354,9 @@ router.get('/session', async (req, res) => {
valid: true, valid: true,
type: decoded.type, type: decoded.type,
expiresIn: Math.floor(remainingTime), expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
}); });
} catch (err) { } catch (err) {
res.json({ res.json({
@@ -263,4 +369,4 @@ router.get('/session', async (req, res) => {
} }
}); });
module.exports = router; module.exports = router;
+164 -45
View File
@@ -37,7 +37,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
res.json({ valid: true }); res.json({ valid: true });
} catch (error) { } catch (error) {
console.error('Error verifying token:', error); console.error('Error verifying token:', error);
res.status(500).json({ error: 'Failed to verify token', details: error.message }); res.status(500).json({ error: 'Failed to verify token' });
} }
}); });
@@ -90,7 +90,7 @@ router.get('/:slug/info', async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('Error fetching gallery info:', error); console.error('Error fetching gallery info:', error);
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message }); res.status(500).json({ error: 'Failed to fetch gallery info' });
} }
}); });
@@ -107,30 +107,83 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
.select('photos.*') .select('photos.*')
.orderBy('photos.uploaded_at', 'desc'); .orderBy('photos.uploaded_at', 'desc');
// Apply filtering if requested // Apply filtering if requested (supports global stats + per-guest interactions)
if (filter && guest_id) { if (filter) {
let filters = {}; const filterTokens = new Set(
String(filter)
// Parse filter parameter .toLowerCase()
if (filter === 'liked') { .split(',')
filters.liked = true; .map(token => token.trim())
} else if (filter === 'favorited') { .filter(Boolean)
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 if (filterTokens.size > 0) {
photos = photos.filter(photo => filteredPhotoIds.includes(photo.id)); // Treat "saved" / "favorite" synonyms as favorites
if (filterTokens.has('saved')) {
filterTokens.add('favorited');
}
if (filterTokens.has('favorite')) {
filterTokens.add('favorited');
}
const include = new Set();
const includeBy = (predicate) => {
photos.forEach(photo => {
if (predicate(photo)) {
include.add(photo.id);
}
});
};
let guestFeedbackByType = null;
if (guest_id) {
const guestFeedbackRows = await db('photo_feedback')
.where({ event_id: req.event.id, guest_identifier: guest_id })
.select('photo_id', 'feedback_type');
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
if (!acc[row.feedback_type]) {
acc[row.feedback_type] = new Set();
}
acc[row.feedback_type].add(row.photo_id);
return acc;
}, {});
}
const includeGuestMatches = (type) => {
const ids = guestFeedbackByType?.[type];
if (ids && ids.size > 0) {
ids.forEach(id => include.add(id));
}
};
if (filterTokens.has('liked')) {
includeGuestMatches('like');
includeBy(photo => (photo.like_count || 0) > 0);
}
if (filterTokens.has('favorited')) {
includeGuestMatches('favorite');
includeBy(photo => (photo.favorite_count || 0) > 0);
}
if (filterTokens.has('rated')) {
includeGuestMatches('rating');
includeBy(photo => (photo.average_rating || 0) > 0);
}
if (filterTokens.has('commented')) {
includeGuestMatches('comment');
const commentedRows = await db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
.groupBy('photo_id')
.select('photo_id');
commentedRows.forEach(row => include.add(row.photo_id));
}
photos = photos.filter(photo => include.has(photo.id));
}
} }
// Then get comment counts separately // Then get comment counts separately
@@ -230,7 +283,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('Error fetching photos:', error); console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos', details: error.message }); res.status(500).json({ error: 'Failed to fetch photos' });
} }
}); });
@@ -384,6 +437,87 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
} }
}); });
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
if (!ids.length) {
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
}
// Clean IDs
const photoIds = ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
// Fetch photos
const photos = await db('photos')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found for selected IDs' });
}
const archiveName = `${req.event.slug}-selected.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
console.error('Zip error:', err);
try { res.status(500).end(); } catch (e) {}
});
archive.pipe(res);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const fs = require('fs');
// Check watermark settings similar to download-all
const watermarkSettings = await watermarkService.getWatermarkSettings();
for (const photo of photos) {
try {
const filePath = resolvePhotoFilePath(req.event, photo);
if (filePath && fs.existsSync(filePath)) {
const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark like download-all
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name });
} else {
archive.file(filePath, { name });
}
}
} catch (e) {
// skip missing/inaccessible files
}
}
await archive.finalize();
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
} catch (error) {
console.error('Error in download-selected:', error);
res.status(500).json({ error: 'Failed to download selected photos' });
}
});
// View single photo (with watermark if enabled) // View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId', router.get('/:slug/photo/:photoId',
@@ -413,18 +547,9 @@ router.get('/:slug/photo/:photoId',
}); });
} }
// Photo path should be in storage/events/active directory // Resolve the absolute file path for this photo, supporting both managed and external reference modes
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename) const { resolvePhotoFilePath } = require('../services/photoResolver');
const storagePath = getStoragePath(); const filePath = resolvePhotoFilePath(req.event, photo);
let filePath;
if (photo.path.startsWith('events/active/')) {
// New format: path already includes events/active/ prefix
filePath = path.join(storagePath, photo.path);
} else {
// Legacy format: path is just slug/filename
filePath = path.join(storagePath, 'events/active', photo.path);
}
// Log access - temporarily disabled for debugging // Log access - temporarily disabled for debugging
@@ -466,7 +591,7 @@ router.get('/:slug/photo/:photoId',
photoId: req.params.photoId, photoId: req.params.photoId,
eventId: req.event?.id eventId: req.event?.id
}); });
res.status(500).json({ error: 'Failed to serve photo', details: error.message }); res.status(500).json({ error: 'Failed to serve photo' });
} }
} }
); );
@@ -632,12 +757,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
// Process uploaded photos // Process uploaded photos
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId); const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
// Clean up temp files
const fs = require('fs').promises;
for (const file of req.files) {
await fs.unlink(file.path).catch(console.error);
}
res.json({ res.json({
message: 'Photos uploaded successfully', message: 'Photos uploaded successfully',
count: results.length, count: results.length,
File diff suppressed because it is too large Load Diff
+86 -16
View File
@@ -7,9 +7,32 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
function normalizeFiles(files) {
if (!files) return [];
if (Array.isArray(files)) return files.filter(Boolean);
// Multer may expose files as an iterable object
if (typeof files[Symbol.iterator] === 'function') {
return Array.from(files).filter(Boolean);
}
if (typeof files === 'object') {
return Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
}
return [];
}
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) { async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
const uploadedPhotos = []; const uploadedPhotos = [];
const fileList = normalizeFiles(files);
if (fileList.length === 0) {
return uploadedPhotos;
}
// Get event details // Get event details
const event = await db('events').where({ id: eventId }).first(); const event = await db('events').where({ id: eventId }).first();
if (!event) { if (!event) {
@@ -17,7 +40,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
} }
// Process each file // Process each file
for (const file of files) { for (const file of fileList) {
const trx = await db.transaction(); const trx = await db.transaction();
try { try {
@@ -35,8 +58,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
.where({ event_id: eventId, type: photoType }) .where({ event_id: eventId, type: photoType })
.count('id as count') .count('id as count')
.first(); .first();
counter = (existingCount.count || 0) + 1; const existingCountValue = Number(existingCount?.count ?? 0);
counter = existingCountValue + 1;
// Generate new filename // Generate new filename
const extension = path.extname(file.originalname); const extension = path.extname(file.originalname);
@@ -53,9 +77,24 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
await fs.mkdir(destPath, { recursive: true }); await fs.mkdir(destPath, { recursive: true });
const newPath = path.join(destPath, newFilename); const newPath = path.join(destPath, newFilename);
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
if (!tempPath) {
throw new Error('Uploaded file is missing a temporary path');
}
// Use copyFile and unlink instead of rename to avoid cross-device issues // Use copyFile and unlink instead of rename to avoid cross-device issues
await fs.copyFile(file.path, newPath); try {
await fs.unlink(file.path); await fs.copyFile(tempPath, newPath);
} finally {
try {
await fs.unlink(tempPath);
} catch (unlinkErr) {
if (unlinkErr?.code !== 'ENOENT') {
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
}
}
}
// Generate thumbnail // Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath); const thumbnailPath = await generateThumbnail(newPath);
@@ -66,15 +105,46 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database with uploaded_by field // Add to database with uploaded_by field
const [photoId] = await trx('photos').insert({ let insertResult;
event_id: eventId, const clientName = trx?.client?.config?.client;
filename: newFilename, const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
path: relativePath,
thumbnail_path: relativeThumbPath, if (supportsReturning) {
type: photoType, insertResult = await trx('photos')
size_bytes: file.size .insert({
}); event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
})
.returning('id');
} else {
insertResult = await trx('photos').insert({
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
});
}
const insertedId = Array.isArray(insertResult)
? (insertResult[0]?.id ?? insertResult[0])
: insertResult;
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
if (photoId === undefined || photoId === null) {
throw new Error('Failed to determine inserted photo ID');
}
// Commit transaction // Commit transaction
await trx.commit(); await trx.commit();
@@ -96,4 +166,4 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
module.exports = { module.exports = {
processUploadedPhotos processUploadedPhotos
}; };
+8 -2
View File
@@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const { resolveExternalPath } = require('./externalMediaService'); const { resolveExternalPath } = require('./externalMediaService');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -33,10 +34,15 @@ function resolvePhotoFilePath(event, photo) {
} }
const storagePath = getStoragePath(); const storagePath = getStoragePath();
const eventsRoot = path.join(storagePath, 'events/active');
if (photo.path && photo.path.startsWith('events/active/')) { if (photo.path && photo.path.startsWith('events/active/')) {
return path.join(storagePath, photo.path); // Legacy paths already include prefix; normalize via safe join
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
} }
return path.join(storagePath, 'events/active', photo.path || '');
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
return safePathJoin(eventsRoot, relativeSegment);
} }
module.exports = { module.exports = {
+262
View File
@@ -0,0 +1,262 @@
const crypto = require('crypto');
const sanitizeHtml = require('sanitize-html');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { sanitizeCss } = require('../utils/cssSanitizer');
const {
DEFAULT_PUBLIC_SITE_TITLE,
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
} = require('../constants/publicSiteDefaults');
const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000);
let cachedPayload = null;
let cacheExpiresAt = 0;
const ALLOWED_HTML_TAGS = [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div',
'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span',
'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
'ul'
];
const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden'];
function parseSettingValue(value) {
if (value === null || value === undefined) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}
async function fetchPublicSiteSettings() {
const rows = await db('app_settings')
.whereIn('setting_key', [
'general_public_site_enabled',
'general_public_site_html',
'general_public_site_custom_css'
]);
const map = {
general_public_site_enabled: false,
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
general_public_site_custom_css: ''
};
rows.forEach((row) => {
const parsed = parseSettingValue(row.setting_value);
map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed;
});
return map;
}
function sanitizeBrandUrl(url) {
if (typeof url !== 'string' || !url.trim()) {
return null;
}
const trimmed = url.trim();
if (trimmed.startsWith('javascript:')) {
return null;
}
return trimmed;
}
async function fetchBrandingContext() {
const rows = await db('app_settings')
.whereIn('setting_key', [
'branding_company_name',
'branding_company_tagline',
'branding_support_email',
'branding_logo_url',
'branding_footer_text',
'theme_config'
]);
const context = {
companyName: null,
companyTagline: null,
supportEmail: null,
logoUrl: null,
footerText: null,
colors: {
primary: '#16a34a',
accent: '#0f766e',
background: '#f4fbf6',
text: '#0f172a'
}
};
rows.forEach((row) => {
const parsed = parseSettingValue(row.setting_value);
switch (row.setting_key) {
case 'branding_company_name':
context.companyName = parsed || context.companyName;
break;
case 'branding_company_tagline':
context.companyTagline = parsed || context.companyTagline;
break;
case 'branding_support_email':
context.supportEmail = parsed || context.supportEmail;
break;
case 'branding_logo_url':
context.logoUrl = sanitizeBrandUrl(parsed);
break;
case 'branding_footer_text':
context.footerText = parsed || context.footerText;
break;
case 'theme_config': {
try {
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
if (themeConfig && typeof themeConfig === 'object') {
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
context.colors.accent = themeConfig.accentColor || context.colors.accent;
context.colors.background = themeConfig.backgroundColor || context.colors.background;
context.colors.text = themeConfig.textColor || context.colors.text;
}
} catch (error) {
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
}
break;
}
default:
break;
}
});
return context;
}
function sanitizeHtmlPayload(html) {
const sanitized = sanitizeHtml(html || '', {
allowedTags: ALLOWED_HTML_TAGS,
allowedAttributes: {
'*': COMMON_ATTRIBUTES,
a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES],
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES],
button: ['type', ...COMMON_ATTRIBUTES]
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
transformTags: {
a: (tagName, attribs) => {
const transformed = { ...attribs };
if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) {
// sanitize-html will remove disallowed schemes, but we guard as well
delete transformed.href;
}
if (transformed.target === '_blank') {
transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer';
}
return { tagName, attribs: transformed };
}
},
nonBooleanAttributes: ['target'],
parser: {
lowerCaseAttributeNames: true
}
});
return sanitized;
}
function buildCachedPayload(raw) {
const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML);
const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || '');
const enabled = Boolean(raw.publicSite.general_public_site_enabled);
const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE;
const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS);
const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding);
const hash = crypto
.createHash('sha1')
.update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`)
.digest('hex');
return {
enabled,
html: substitutedHtml,
css: sanitizedCss,
baseCss,
title,
branding: raw.branding,
etag: `W/"${hash}"`
};
}
async function getPublicSitePayload({ bypassCache = false } = {}) {
if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) {
return cachedPayload;
}
const [publicSite, branding] = await Promise.all([
fetchPublicSiteSettings(),
fetchBrandingContext()
]);
const payload = buildCachedPayload({ publicSite, branding });
cachedPayload = payload;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return payload;
}
function clearPublicSiteCache() {
cachedPayload = null;
cacheExpiresAt = 0;
}
async function getDefaultPublicSitePayload() {
const branding = await fetchBrandingContext();
return buildCachedPayload({
publicSite: {
general_public_site_enabled: false,
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
general_public_site_custom_css: ''
},
branding
});
}
async function getRawPublicSiteSettings() {
return fetchPublicSiteSettings();
}
function applyBrandTokens(html, branding) {
if (!html) {
return html;
}
const tokens = {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
brand_primary_hex: branding.colors?.primary || '#2563eb',
brand_accent_hex: branding.colors?.accent || '#1d4ed8',
brand_background_hex: branding.colors?.background || '#f8fafc',
brand_text_hex: branding.colors?.text || '#0f172a'
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
(_, key) => tokens[key] || '');
}
module.exports = {
getPublicSitePayload,
clearPublicSiteCache,
getDefaultPublicSitePayload,
getRawPublicSiteSettings
};
+5 -7
View File
@@ -2,6 +2,7 @@ const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
// Cache for rate limit settings // Cache for rate limit settings
let settingsCache = null; let settingsCache = null;
@@ -95,12 +96,9 @@ function clearSettingsCache() {
*/ */
function isAuthenticated(req) { function isAuthenticated(req) {
try { try {
const authHeader = req.headers.authorization; const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
if (!authHeader || !authHeader.startsWith('Bearer ')) { const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
return false; const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token is valid // Check if token is valid
@@ -280,4 +278,4 @@ module.exports = {
createAuthRateLimiter, createAuthRateLimiter,
isAuthenticated, isAuthenticated,
shouldSkipRateLimit shouldSkipRateLimit
}; };
@@ -1,5 +1,5 @@
const S3StorageAdapter = require('../s3Storage'); const S3StorageAdapter = require('../s3Storage');
const { S3Client } = require('@aws-sdk/client-s3'); const { S3Client, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage'); const { Upload } = require('@aws-sdk/lib-storage');
const fs = require('fs'); const fs = require('fs');
const stream = require('stream'); const stream = require('stream');
@@ -24,6 +24,10 @@ describe('S3StorageAdapter', () => {
send: mockSend send: mockSend
}; };
S3Client.mockImplementation(() => mockS3Client); S3Client.mockImplementation(() => mockS3Client);
HeadBucketCommand.mockImplementation((input) => ({ input }));
HeadObjectCommand.mockImplementation((input) => ({ input }));
ListObjectsV2Command.mockImplementation((input) => ({ input }));
// Create adapter instance // Create adapter instance
s3Storage = new S3StorageAdapter({ s3Storage = new S3StorageAdapter({
@@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => {
const result = await s3Storage.testConnection(); const result = await s3Storage.testConnection();
expect(result).toBe(true); expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith( expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' });
expect.objectContaining({
input: { Bucket: 'test-bucket' }
})
);
}); });
it('should throw error on connection failure', async () => { it('should throw error on connection failure', async () => {
@@ -132,24 +132,25 @@ describe('S3StorageAdapter', () => {
it('should track upload progress', async () => { it('should track upload progress', async () => {
const onProgress = jest.fn(); const onProgress = jest.fn();
let progressCallback; Upload.mockImplementation(() => {
const uploadInstance = {
mockUpload.on.mockImplementation((event, callback) => { on: jest.fn((event, handler) => {
if (event === 'httpUploadProgress') { if (event === 'httpUploadProgress') {
progressCallback = callback; handler({ loaded: 512, total: 1024 });
} }
return mockUpload; return uploadInstance;
}),
done: mockDone
};
return uploadInstance;
}); });
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', { const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
onProgress onProgress
}); });
// Simulate progress
progressCallback({ loaded: 512, total: 1024 });
await uploadPromise; await uploadPromise;
expect(onProgress).toHaveBeenCalledWith(512, 1024); expect(onProgress).toHaveBeenCalledWith(512, 1024);
}); });
@@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => {
const result = await s3Storage.exists('test-key'); const result = await s3Storage.exists('test-key');
expect(result).toBe(true); expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith( expect(HeadObjectCommand).toHaveBeenCalledWith({
expect.objectContaining({ Bucket: 'test-bucket',
input: { Bucket: 'test-bucket', Key: 'test-key' } Key: 'test-key'
}) });
);
}); });
it('should return false if object does not exist', async () => { it('should return false if object does not exist', async () => {
@@ -220,58 +220,51 @@ describe('S3StorageAdapter', () => {
it('should retry on retryable errors', async () => { it('should retry on retryable errors', async () => {
const retryableError = new Error('Connection reset'); const retryableError = new Error('Connection reset');
retryableError.code = 'ECONNRESET'; retryableError.code = 'ECONNRESET';
// First attempt fails, second succeeds const operation = jest.fn()
mockSend
.mockRejectedValueOnce(retryableError) .mockRejectedValueOnce(retryableError)
.mockResolvedValueOnce({}); .mockResolvedValueOnce('success');
// Mock setTimeout to speed up test const originalRandom = Math.random;
jest.useFakeTimers(); const originalDelay = s3Storage.config.retryDelay;
Math.random = jest.fn(() => 0);
const promise = s3Storage.exists('test-key'); s3Storage.config.retryDelay = 0;
// Advance timers const result = await s3Storage._retryOperation(operation);
jest.runAllTimers();
expect(result).toBe('success');
const result = await promise; expect(operation).toHaveBeenCalledTimes(2);
expect(result).toBe(true); Math.random = originalRandom;
expect(mockSend).toHaveBeenCalledTimes(2); s3Storage.config.retryDelay = originalDelay;
jest.useRealTimers();
}); });
it('should not retry on non-retryable errors', async () => { it('should not retry on non-retryable errors', async () => {
const nonRetryableError = new Error('Invalid credentials'); const nonRetryableError = new Error('Invalid credentials');
nonRetryableError.code = 'InvalidCredentials'; nonRetryableError.code = 'InvalidCredentials';
mockSend.mockRejectedValueOnce(nonRetryableError); const operation = jest.fn().mockRejectedValueOnce(nonRetryableError);
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials'); await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials');
expect(mockSend).toHaveBeenCalledTimes(1); expect(operation).toHaveBeenCalledTimes(1);
}); });
it('should stop retrying after max attempts', async () => { it('should stop retrying after max attempts', async () => {
const retryableError = new Error('Service unavailable'); const retryableError = new Error('Service unavailable');
retryableError.code = 'ServiceUnavailable'; retryableError.code = 'ServiceUnavailable';
mockSend.mockRejectedValue(retryableError); const operation = jest.fn().mockRejectedValue(retryableError);
// Mock setTimeout to speed up test const originalRandom = Math.random;
jest.useFakeTimers(); const originalDelay = s3Storage.config.retryDelay;
Math.random = jest.fn(() => 0);
const promise = s3Storage.exists('test-key'); s3Storage.config.retryDelay = 0;
// Advance timers for all retries await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Service unavailable');
for (let i = 0; i < 4; i++) { expect(operation).toHaveBeenCalledTimes(4); // initial + 3 retries
jest.runAllTimers();
} Math.random = originalRandom;
s3Storage.config.retryDelay = originalDelay;
await expect(promise).rejects.toThrow('Service unavailable');
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
jest.useRealTimers();
}); });
}); });
@@ -308,4 +301,4 @@ describe('S3StorageAdapter', () => {
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB'); expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
}); });
}); });
}); });
+32
View File
@@ -0,0 +1,32 @@
function sanitizeCss(css) {
if (!css || typeof css !== 'string') {
return '';
}
let sanitized = css;
const disallowedPatterns = [
/@import[^;]+;?/gi,
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
sanitized = sanitized.replace(pattern, '');
});
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
const MAX_LENGTH = 100 * 1024;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.slice(0, MAX_LENGTH);
}
return sanitized.trim();
}
module.exports = {
sanitizeCss,
};
+129
View File
@@ -0,0 +1,129 @@
const ADMIN_COOKIE_NAME = 'admin_token';
const GALLERY_COOKIE_NAME = 'gallery_token';
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
const secureCookie = (() => {
if (typeof process.env.COOKIE_SECURE === 'string') {
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
}
// Default to false so native HTTP installs stay functional. Operators can
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
return false;
})();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN;
function buildCookieBaseOptions() {
const options = {
httpOnly: true,
secure: secureCookie,
sameSite: sameSiteDefault,
path: '/',
};
if (cookieDomain) {
options.domain = cookieDomain;
}
return options;
}
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
return {
...buildCookieBaseOptions(),
maxAge: maxAgeMs,
};
}
function sanitizeSlugForCookie(slug = '') {
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
}
function setAdminAuthCookie(res, token) {
if (!token) return;
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
}
function clearAdminAuthCookie(res) {
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
}
function setGalleryAuthCookies(res, token, slug) {
if (!token) return;
const options = buildCookieOptionsWithExpiry();
res.cookie(GALLERY_COOKIE_NAME, token, options);
if (slug) {
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
res.cookie(cookieName, token, options);
}
}
function clearGalleryAuthCookies(res, slug) {
const baseOptions = buildCookieBaseOptions();
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
const cookies = res.req?.cookies || {};
if (slug) {
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
res.clearCookie(cookieName, baseOptions);
} else {
Object.keys(cookies).forEach((name) => {
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
res.clearCookie(name, baseOptions);
}
});
}
}
function getAdminTokenFromRequest(req) {
const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) {
return header.substring(7);
}
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
}
function getGalleryTokenFromRequest(req, slug) {
const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) {
return header.substring(7);
}
if (!req.cookies) {
return null;
}
if (slug) {
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
if (req.cookies[cookieName]) {
return req.cookies[cookieName];
}
}
if (req.cookies[GALLERY_COOKIE_NAME]) {
return req.cookies[GALLERY_COOKIE_NAME];
}
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GALLERY_COOKIE_PREFIX));
if (prefixed) {
return req.cookies[prefixed];
}
return null;
}
module.exports = {
ADMIN_COOKIE_NAME,
GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX,
sanitizeSlugForCookie,
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
};
+8 -1
View File
@@ -11,6 +11,7 @@ services:
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- DATABASE_CLIENT=pg - DATABASE_CLIENT=pg
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME} - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
- DB_TYPE=postgresql - DB_TYPE=postgresql
@@ -19,6 +20,7 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media}
- SMTP_HOST=${SMTP_HOST} - SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_SECURE=${SMTP_SECURE:-false}
@@ -29,6 +31,11 @@ services:
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001} - ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
- STORAGE_PATH=/app/storage - STORAGE_PATH=/app/storage
# Optional: run container as matching host user to avoid bind mount permission issues
- PUID=${PUID:-1001}
- PGID=${PGID:-1001}
# Use host-matching user ID/GID so bind-mounted folders are writable
user: "${PUID:-1001}:${PGID:-1001}"
volumes: volumes:
- ./events:/app/events - ./events:/app/events
- ./data:/app/data - ./data:/app/data
@@ -122,4 +129,4 @@ volumes:
networks: networks:
picpeak-network: picpeak-network:
driver: bridge driver: bridge
+3 -1
View File
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
- Check the console output from when you ran migrations - Check the console output from when you ran migrations
- File is created in the backend directory root - File is created in the backend directory root
- File might have been deleted for security (as recommended) - File might have been deleted for security (as recommended)
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
## Best Practices ## Best Practices
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
- [ ] Stored new password in password manager - [ ] Stored new password in password manager
- [ ] Tested login with new password - [ ] Tested login with new password
- [ ] Set up additional admin accounts if needed - [ ] Set up additional admin accounts if needed
- [ ] Configured password policies for organization - [ ] Configured password policies for organization
+14 -1
View File
@@ -101,6 +101,19 @@ server {
proxy_cache_valid 404 1m; proxy_cache_valid 404 1m;
} }
# Delegate root requests to backend for public landing page handling
location = / {
proxy_pass http://backend:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# SPA fallback # SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@@ -110,4 +123,4 @@ server {
location ~ /\. { location ~ /\. {
deny all; deny all;
} }
} }
+36 -47
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.121", "version": "1.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.121", "version": "1.1.4",
"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",
@@ -20,14 +20,14 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9", "@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2", "axios": "^1.12.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"i18next": "^25.3.1", "i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"js-cookie": "^3.0.5", "linkifyjs": "^4.3.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "0.525.0", "lucide-react": "0.525.0",
@@ -44,7 +44,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
@@ -57,7 +56,7 @@
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.34.1", "typescript-eslint": "^8.34.1",
"vite": "^7.0.0" "vite": "^7.1.6"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -2014,13 +2013,6 @@
"@types/unist": "^2" "@types/unist": "^2"
} }
}, },
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2549,13 +2541,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.10.0", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -3903,15 +3895,6 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4032,9 +4015,9 @@
} }
}, },
"node_modules/linkifyjs": { "node_modules/linkifyjs": {
"version": "4.3.1", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/locate-path": { "node_modules/locate-path": {
@@ -5524,14 +5507,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -5541,11 +5524,14 @@
} }
}, },
"node_modules/tinyglobby/node_modules/fdir": { "node_modules/tinyglobby/node_modules/fdir": {
"version": "6.4.6", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
}, },
@@ -5731,18 +5717,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.0.5", "version": "7.1.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz",
"integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.5.0",
"picomatch": "^4.0.2", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.40.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -5806,11 +5792,14 @@
} }
}, },
"node_modules/vite/node_modules/fdir": { "node_modules/vite/node_modules/fdir": {
"version": "6.4.6", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
}, },
+4 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.0.121", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -23,14 +23,14 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9", "@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2", "axios": "^1.12.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"i18next": "^25.3.1", "i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"js-cookie": "^3.0.5", "linkifyjs": "^4.3.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "0.525.0", "lucide-react": "0.525.0",
@@ -47,7 +47,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
@@ -60,6 +59,6 @@
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.34.1", "typescript-eslint": "^8.34.1",
"vite": "^7.0.0" "vite": "^7.1.6"
} }
} }
-1
View File
@@ -22,7 +22,6 @@ import {
AnalyticsPage, AnalyticsPage,
BrandingPage, BrandingPage,
SettingsPage, SettingsPage,
CMSPage,
BackupManagement BackupManagement
} from './pages/admin'; } from './pages/admin';
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced'; import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
+35 -7
View File
@@ -1,9 +1,9 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode'; import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext'; import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api'; import { setMaintenanceModeCallback, api } from '../config/api';
interface MaintenanceWrapperProps { interface MaintenanceWrapperProps {
children: React.ReactNode; children: React.ReactNode;
@@ -12,10 +12,38 @@ interface MaintenanceWrapperProps {
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => { export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation(); const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
// Check if current route is admin route // Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin'); const isAdminRoute = location.pathname.startsWith('/admin');
const hasAdminAuth = !!getAuthToken(true);
useEffect(() => {
let isMounted = true;
const checkAdminSession = async () => {
if (!isAdminRoute) {
setHasAdminSession(false);
return;
}
try {
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
if (isMounted) {
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
}
} catch (error) {
if (isMounted) {
setHasAdminSession(false);
}
}
};
checkAdminSession();
return () => {
isMounted = false;
};
}, [isAdminRoute]);
// Register the maintenance mode callback // Register the maintenance mode callback
useEffect(() => { useEffect(() => {
@@ -37,7 +65,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
} catch (error: any) { } catch (error: any) {
if (error.response?.status === 503) { if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes // Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) { if (!isAdminRoute || !hasAdminSession) {
setMaintenanceMode(true); setMaintenanceMode(true);
return { maintenance: true }; return { maintenance: true };
} }
@@ -47,13 +75,13 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
}, },
staleTime: 30000, // Check every 30 seconds staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
}); });
// Show maintenance page if in maintenance mode and not on admin route with auth // Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) { if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
return <MaintenanceMode />; return <MaintenanceMode />;
} }
return <>{children}</>; return <>{children}</>;
}; };
+12 -18
View File
@@ -71,35 +71,29 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200"> <header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
<div className="px-4 sm:px-6 lg:px-8"> <div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
{/* Left side - Menu button and Date */} {/* Left side - Menu button, Logo, and Date */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<button <button
onClick={onMenuClick} onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700" className="lg:hidden text-neutral-500 hover:text-neutral-700"
> >
<Menu className="w-6 h-6" /> <Menu className="w-6 h-6" />
</button> </button>
{/* Date display - hidden on small screens */} {/* PicPeak logo - sticky to the left on all sizes */}
<div className="hidden xl:block"> <div className="flex items-center gap-2">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Date display - hidden on smaller screens */}
<div className="hidden xl:block pl-3 border-l border-neutral-200 ml-1">
<p className="text-base text-neutral-700"> <p className="text-base text-neutral-700">
{format(new Date(), 'PPPP')} {format(new Date(), 'PPPP')}
</p> </p>
</div> </div>
</div> </div>
{/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */}
<div className="hidden lg:flex absolute left-1/2 transform -translate-x-1/2 items-center gap-3">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Mobile Logo - shown only on small screens */}
<div className="flex lg:hidden items-center gap-2 mx-auto">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Right side actions */} {/* Right side actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Language Selector */} {/* Language Selector */}
@@ -253,4 +247,4 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
/> />
</header> </header>
); );
}; };
@@ -29,7 +29,10 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
if (e) { if (e) {
e.stopPropagation(); e.stopPropagation();
} }
// Auto-enable selection mode when selecting via checkbox
if (!isSelectionMode) {
setIsSelectionMode(true);
}
const newSelected = new Set(selectedPhotos); const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) { if (newSelected.has(photoId)) {
newSelected.delete(photoId); newSelected.delete(photoId);
@@ -126,7 +129,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'} {isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button> </Button>
{isSelectionMode && ( {(isSelectionMode || selectedPhotos.size > 0) && (
<> <>
<Button <Button
variant="ghost" variant="ghost"
@@ -164,28 +167,38 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => { {photos.map((photo, index) => {
const isDeleting = deletingPhotos.has(photo.id); const isDeleting = deletingPhotos.has(photo.id);
const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 0;
return ( return (
<div <div
key={photo.id} key={photo.id}
data-testid={`admin-photo-tile-${photo.id}`}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${ className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : '' isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
} ${isDeleting ? 'opacity-50' : ''}`} } ${isDeleting ? 'opacity-50' : ''}`}
onClick={() => !isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))} onClick={() => !isDeleting && onPhotoClick(photo, index)}
> >
{/* Selection Checkbox */} {/* Selection Checkbox (top-right) */}
{isSelectionMode && ( <button
<div className="absolute top-2 left-2 z-10"> type="button"
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${ aria-label={`Select ${photo.filename}`}
selectedPhotos.has(photo.id) role="checkbox"
? 'bg-primary-500 border-primary-500' aria-checked={selectedPhotos.has(photo.id)}
: 'bg-white/80 border-neutral-300' data-testid={`admin-photo-checkbox-${photo.id}`}
}`}> className={`absolute top-2 right-2 z-20 transition-opacity ${
{selectedPhotos.has(photo.id) && ( selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
<Check className="w-4 h-4 text-white" /> }`}
)} onClick={(e) => handlePhotoSelect(photo.id, e)}
</div> >
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-600 border-primary-600'
: 'bg-white/90 border-white'
}`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-square"> <div className="aspect-square">
@@ -238,28 +251,28 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div> </div>
</div> </div>
{/* Category Badge */} {/* Category Badge - move to top-left and prevent overlap with select checkbox */}
{photo.category_name && ( {photo.category_name && (
<div className="absolute top-2 right-2"> <div className="absolute left-2 top-2 pointer-events-none">
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded"> <span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded max-w-[70%] whitespace-nowrap overflow-hidden text-ellipsis">
{photo.category_name} {photo.category_name}
</span> </span>
</div> </div>
)} )}
{/* Feedback Indicators */} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( {(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10" style={{ left: isSelectionMode ? '40px' : '8px' }}> <div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
{photo.comment_count > 0 && ( {averageRating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span> <span className="text-xs font-medium text-neutral-700">{Number(averageRating).toFixed(1)}</span>
</div> </div>
)} )}
{photo.average_rating > 0 && ( {commentCount > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span> <span className="text-xs font-medium text-neutral-700">{commentCount}</span>
</div> </div>
)} )}
</div> </div>
@@ -276,4 +289,4 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
)} )}
</div> </div>
); );
}; };
@@ -1,15 +1,20 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AdminPhoto } from '../../services/photos.service'; import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common'; import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
summary?: FeedbackSummary;
};
interface AdminPhotoViewerProps { interface AdminPhotoViewerProps {
photos: AdminPhoto[]; photos: AdminPhoto[];
initialIndex: number; initialIndex: number;
@@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
const averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0;
if (!currentPhoto) {
return null;
}
// Fetch feedback for current photo // Fetch feedback for current photo
const { data: feedbackData } = useQuery({ const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id], queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
photoId: currentPhoto?.id.toString(), photoId: currentPhoto?.id.toString(),
@@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
enabled: !!currentPhoto enabled: !!currentPhoto
}); });
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
const goToPrevious = () => { const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
}; };
@@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{/* Feedback Stats */} {/* Feedback Stats */}
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
{currentPhoto.average_rating > 0 && ( {averageRating > 0 && (
<div className="bg-neutral-800 rounded-lg p-3"> <div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-yellow-400 mb-1"> <div className="flex items-center gap-1 text-yellow-400 mb-1">
<Star className="w-4 h-4" fill="currentColor" /> <Star className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span> <span className="text-white font-medium">{Number(averageRating).toFixed(1)}</span>
</div> </div>
<p className="text-xs text-neutral-400">Avg Rating</p> <p className="text-xs text-neutral-400">Avg Rating</p>
</div> </div>
)} )}
{currentPhoto.like_count > 0 && ( {likeCount > 0 && (
<div className="bg-neutral-800 rounded-lg p-3"> <div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-red-400 mb-1"> <div className="flex items-center gap-1 text-red-400 mb-1">
<Heart className="w-4 h-4" fill="currentColor" /> <Heart className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{currentPhoto.like_count}</span> <span className="text-white font-medium">{likeCount}</span>
</div> </div>
<p className="text-xs text-neutral-400">Likes</p> <p className="text-xs text-neutral-400">Likes</p>
</div> </div>
)} )}
{currentPhoto.favorite_count > 0 && ( {favoriteCount > 0 && (
<div className="bg-neutral-800 rounded-lg p-3"> <div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-blue-400 mb-1"> <div className="flex items-center gap-1 text-blue-400 mb-1">
<Star className="w-4 h-4" /> <Star className="w-4 h-4" />
<span className="text-white font-medium">{currentPhoto.favorite_count}</span> <span className="text-white font-medium">{favoriteCount}</span>
</div> </div>
<p className="text-xs text-neutral-400">Favorites</p> <p className="text-xs text-neutral-400">Favorites</p>
</div> </div>
)} )}
{feedbackData.feedback && ( {comments.length > 0 && (
<div className="bg-neutral-800 rounded-lg p-3"> <div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-green-400 mb-1"> <div className="flex items-center gap-1 text-green-400 mb-1">
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4" />
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span> <span className="text-white font-medium">{comments.length}</span>
</div> </div>
<p className="text-xs text-neutral-400">Comments</p> <p className="text-xs text-neutral-400">Comments</p>
</div> </div>
@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
</div> </div>
{/* Comments List */} {/* Comments List */}
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && ( {comments.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={() => setExpandedComments(!expandedComments)} onClick={() => setExpandedComments(!expandedComments)}
className="text-xs text-primary-400 hover:text-primary-300 mb-2" className="text-xs text-primary-400 hover:text-primary-300 mb-2"
> >
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}) {expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
</button> </button>
{expandedComments && ( {expandedComments && (
<div className="space-y-3 max-h-64 overflow-y-auto"> <div className="space-y-3 max-h-64 overflow-y-auto">
{feedbackData.feedback {comments.map((comment) => (
.filter(f => f.feedback_type === 'comment')
.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3"> <div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex-1"> <div className="flex-1">
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
)} )}
{/* No feedback message */} {/* No feedback message */}
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && ( {comments.length === 0 && (
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p> <p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
)} )}
</div> </div>
@@ -475,4 +487,4 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
</div> </div>
</div> </div>
); );
}; };
+14 -5
View File
@@ -121,11 +121,20 @@ const StorageInfo: React.FC = () => {
); );
} }
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100); const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
const usagePercent = limitInUse
? Math.round((storageInfo.total_used / limitInUse) * 100)
: 0;
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
const containerClass = isOverSoftLimit
? 'bg-red-50 border border-red-200'
: 'bg-neutral-100';
const softLimitDisplay = settingsService.formatBytes(limitInUse);
return ( return (
<div className="p-4 border-t border-neutral-200"> <div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3"> <div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-neutral-700">{t('admin.storageUsed')}</span> <span className="text-neutral-700">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900"> <span className="font-medium text-neutral-900">
@@ -134,14 +143,14 @@ const StorageInfo: React.FC = () => {
</div> </div>
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2"> <div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
<div <div
className="bg-primary-600 h-2 rounded-full transition-all duration-300" className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(usagePercent, 100)}%` }} style={{ width: `${Math.min(usagePercent, 100)}%` }}
/> />
</div> </div>
<p className="text-xs text-neutral-600 mt-1"> <p className="text-xs text-neutral-600 mt-1">
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })} {t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
</p> </p>
</div> </div>
</div> </div>
); );
}; };
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupConfiguration: ComponentType<any>;
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupDashboard: ComponentType<any>;
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupHistory: ComponentType<any>;
+7 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break'; import HardBreak from '@tiptap/extension-hard-break';
@@ -107,10 +107,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
}, },
}); });
const updateCounts = useCallback((editor: any) => { const updateCounts = useCallback((editorInstance: Editor) => {
const text = editor.state.doc.textContent; const textContent = editorInstance.state.doc.textContent;
setCharCount(editor.storage.characterCount.characters()); setCharCount(editorInstance.storage.characterCount.characters());
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length); const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
setWordCount(words.length);
}, []); }, []);
// Update editor content when prop changes // Update editor content when prop changes
@@ -568,4 +569,4 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
); );
}; };
CMSEditor.displayName = 'CMSEditor'; CMSEditor.displayName = 'CMSEditor';
@@ -1,14 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
MessageSquare, MessageSquare,
Eye, EyeOff,
EyeOff, Trash2,
Trash2,
AlertCircle,
CheckCircle, CheckCircle,
Clock,
User User
} from 'lucide-react'; } from 'lucide-react';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
@@ -16,7 +13,7 @@ import { toast } from 'react-toastify';
import { Card, Loading, Button } from '../common'; import { Card, Loading, Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface FeedbackModerationPanelProps { interface FeedbackModerationPanelProps {
@@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
// Fetch pending feedback // Fetch pending feedback
const { data: feedbackData, isLoading } = useQuery({ const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
queryKey: ['event-feedback-moderation', eventId], queryKey: ['event-feedback-moderation', eventId],
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
type: 'comment', type: 'comment',
@@ -77,12 +74,12 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
); );
} }
const pendingComments = feedbackData?.feedback || []; const pendingComments: PhotoFeedback[] = feedbackData?.feedback || [];
const hasPending = pendingComments.length > 0; const hasPending = pendingComments.length > 0;
return ( return (
<Card className={className}> <Card className={className}>
<div className="p-6"> <div className={compact ? 'p-4' : 'p-6'}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900"> <h2 className="text-lg font-semibold text-neutral-900">
{t('feedback.pendingModeration', 'Pending Moderation')} {t('feedback.pendingModeration', 'Pending Moderation')}
@@ -132,7 +129,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<div className="w-16 h-16 overflow-hidden rounded"> <div className="w-16 h-16 overflow-hidden rounded">
<AdminAuthenticatedImage <AdminAuthenticatedImage
src={`/api/admin/photos/${eventId}/thumbnail/${item.photo_id}`} src={`/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
alt={item.filename || 'Photo'} alt={item.filename || 'Photo'}
className="w-16 h-16 object-cover rounded" className="w-16 h-16 object-cover rounded"
/> />
@@ -1,9 +1,19 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Camera } from 'lucide-react'; import { Camera } from 'lucide-react';
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types'; import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
import { buildResourceUrl } from '../../utils/url';
interface GalleryPreviewBranding {
company_name?: string;
company_tagline?: string;
logo_url?: string;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
logo_position?: 'left' | 'center' | 'right';
}
interface GalleryPreviewProps { interface GalleryPreviewProps {
theme: ThemeConfig; theme: ThemeConfig;
branding?: GalleryPreviewBranding;
layoutType?: GalleryLayoutType; layoutType?: GalleryLayoutType;
className?: string; className?: string;
} }
@@ -56,6 +66,7 @@ const PreviewPhoto: React.FC<{
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
theme, theme,
branding,
layoutType, layoutType,
className = '' className = ''
}) => { }) => {
@@ -63,6 +74,23 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
// Use the provided layoutType or fallback to theme's gallery layout // Use the provided layoutType or fallback to theme's gallery layout
const activeLayout = layoutType || theme.galleryLayout || 'grid'; const activeLayout = layoutType || theme.galleryLayout || 'grid';
const displayMode = branding?.logo_display_mode || 'logo_and_text';
const showLogo = displayMode === 'logo_only' || displayMode === 'logo_and_text';
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
const brandName = branding?.company_name?.trim() || 'Your Studio';
const brandTagline = branding?.company_tagline?.trim() || '';
const resolvedLogoUrl = showLogo && branding?.logo_url
? (branding.logo_url.startsWith('http')
? branding.logo_url
: buildResourceUrl(branding.logo_url))
: null;
const logoPosition = branding?.logo_position || 'left';
const brandFlexClass = logoPosition === 'center'
? 'justify-center text-center'
: logoPosition === 'right'
? 'justify-end text-right flex-row-reverse'
: 'justify-start text-left';
const renderLayout = () => { const renderLayout = () => {
const spacing = theme.gallerySettings?.spacing || 'normal'; const spacing = theme.gallerySettings?.spacing || 'normal';
@@ -163,14 +191,41 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
> >
{/* Preview Header */} {/* Preview Header */}
<div <div
className="px-4 py-3 border-b" className="px-4 py-3 border-b space-y-2"
style={{ style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb', borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}} }}
> >
<h3 className="text-sm font-medium"> <div className={`flex items-center gap-3 ${brandFlexClass}`}>
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout {showLogo && (
</h3> resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-8 w-auto object-contain"
/>
) : (
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
<Camera className="w-4 h-4 text-neutral-500" />
</div>
)
)}
{showText && (
<div>
<p className="text-sm font-semibold leading-tight">{brandName}</p>
{brandTagline && (
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
)}
</div>
)}
{!showLogo && !showText && (
<p className="text-sm font-semibold">{brandName}</p>
)}
</div>
<div className="text-xs text-neutral-500 flex justify-between">
<span>Gallery preview</span>
<span className="capitalize">{activeLayout} layout</span>
</div>
</div> </div>
{/* Preview Content */} {/* Preview Content */}
@@ -181,4 +236,4 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
); );
}; };
GalleryPreview.displayName = 'GalleryPreview'; GalleryPreview.displayName = 'GalleryPreview';
@@ -1,341 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
interface ProtectionSettings {
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
default_image_quality: number;
enable_devtools_protection: boolean;
max_image_requests_per_minute: number;
suspicious_activity_threshold: number;
enable_canvas_rendering: boolean;
default_fragmentation_level: number;
enable_overlay_protection: boolean;
protection_warning_message: string;
}
export const ImageProtectionSettings: React.FC = () => {
const { t } = useTranslation();
const [settings, setSettings] = useState<ProtectionSettings>({
default_protection_level: 'standard',
default_image_quality: 85,
enable_devtools_protection: true,
max_image_requests_per_minute: 30,
suspicious_activity_threshold: 10,
enable_canvas_rendering: false,
default_fragmentation_level: 3,
enable_overlay_protection: true,
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setIsLoading(true);
const response = await settingsService.getSettings();
// Map settings from API response
const protectionSettings: ProtectionSettings = {
default_protection_level: response.default_protection_level || 'standard',
default_image_quality: parseInt(response.default_image_quality) || 85,
enable_devtools_protection: response.enable_devtools_protection !== false,
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
enable_canvas_rendering: response.enable_canvas_rendering === true,
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
enable_overlay_protection: response.enable_overlay_protection !== false,
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
};
setSettings(protectionSettings);
} catch (error) {
console.error('Failed to load protection settings:', error);
toast.error('Failed to load protection settings');
} finally {
setIsLoading(false);
}
};
const saveSettings = async () => {
try {
setIsSaving(true);
// Convert settings to API format
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
acc[key] = typeof value === 'boolean' ? value : value.toString();
return acc;
}, {} as Record<string, string | boolean>);
await settingsService.updateSettings(apiSettings);
toast.success('Protection settings saved successfully');
} catch (error) {
console.error('Failed to save protection settings:', error);
toast.error('Failed to save protection settings');
} finally {
setIsSaving(false);
}
};
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const protectionLevels = [
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
];
const getProtectionLevelIcon = (level: string) => {
switch (level) {
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
default: return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getProtectionLevelDescription = (level: string) => {
switch (level) {
case 'basic':
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
case 'standard':
return 'Adds keyboard shortcut blocking and user selection prevention.';
case 'enhanced':
return 'Includes DevTools detection, rate limiting, and overlay protection.';
case 'maximum':
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
default:
return '';
}
};
if (isLoading) {
return (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-blue-500" />
<div>
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
</div>
</div>
<div className="space-y-6">
{/* Protection Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Protection Level
</label>
<Select
value={settings.default_protection_level}
onChange={(value) => updateSetting('default_protection_level', value as any)}
options={protectionLevels}
className="w-full"
/>
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 mb-1">
{getProtectionLevelIcon(settings.default_protection_level)}
<span className="font-medium text-sm capitalize">
{settings.default_protection_level} Protection
</span>
</div>
<p className="text-sm text-gray-600">
{getProtectionLevelDescription(settings.default_protection_level)}
</p>
</div>
</div>
{/* Image Quality */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Image Quality ({settings.default_image_quality}%)
</label>
<input
type="range"
min="30"
max="100"
step="5"
value={settings.default_image_quality}
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Lower quality = Better protection</span>
<span>Higher quality = Better image</span>
</div>
</div>
{/* DevTools Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
DevTools Protection
</label>
<p className="text-xs text-gray-500">
Detect and respond to browser developer tools
</p>
</div>
<Toggle
checked={settings.enable_devtools_protection}
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
/>
</div>
{/* Canvas Rendering */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Canvas Rendering
</label>
<p className="text-xs text-gray-500">
Render images on canvas instead of img tags (stronger protection)
</p>
</div>
<Toggle
checked={settings.enable_canvas_rendering}
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
/>
</div>
{/* Fragmentation Level */}
{settings.enable_canvas_rendering && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Fragmentation Level ({settings.default_fragmentation_level})
</label>
<input
type="range"
min="1"
max="5"
step="1"
value={settings.default_fragmentation_level}
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Low fragmentation</span>
<span>High fragmentation</span>
</div>
</div>
)}
{/* Rate Limiting */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Requests Per Minute
</label>
<Input
type="number"
min="5"
max="100"
value={settings.max_image_requests_per_minute}
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Suspicious Activity Threshold
</label>
<Input
type="number"
min="3"
max="50"
value={settings.suspicious_activity_threshold}
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
/>
</div>
</div>
{/* Overlay Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Overlay Protection
</label>
<p className="text-xs text-gray-500">
Add transparent overlays to prevent easy screenshot extraction
</p>
</div>
<Toggle
checked={settings.enable_overlay_protection}
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
/>
</div>
{/* Warning Message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Protection Warning Message
</label>
<Textarea
value={settings.protection_warning_message}
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
placeholder="Message shown when protection is triggered"
rows={3}
/>
</div>
</div>
{/* Warning Box */}
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
<ul className="text-sm text-amber-700 space-y-1">
<li> Higher protection levels may impact page performance</li>
<li> Canvas rendering disables browser image caching</li>
<li> Maximum protection may cause accessibility issues</li>
<li> Test thoroughly with your target browsers</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-end gap-3">
<Button
variant="outline"
onClick={loadSettings}
disabled={isSaving}
>
Reset
</Button>
<Button
variant="primary"
onClick={saveSettings}
loading={isSaving}
>
Save Settings
</Button>
</div>
</Card>
</div>
);
};
@@ -94,7 +94,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} }
try { try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, { await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
if (progressEvent.total) { if (progressEvent.total) {
// Calculate overall progress across all chunks // Calculate overall progress across all chunks
@@ -276,4 +276,4 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
); );
}; };
PhotoUpload.displayName = 'PhotoUpload'; PhotoUpload.displayName = 'PhotoUpload';
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const RestoreWizard: ComponentType<any>;
@@ -37,8 +37,8 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
rows={rows} rows={rows}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm" className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
/> />
<div className="absolute top-2 right-2 text-neutral-400"> <div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" /> <HelpCircle className="w-4 h-4" aria-hidden="true" />
</div> </div>
</div> </div>
@@ -61,4 +61,4 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
); );
}; };
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor'; WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -7,6 +6,23 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
fallbackSrc?: string; fallbackSrc?: string;
useWatermark?: boolean; useWatermark?: boolean;
isGallery?: boolean; isGallery?: boolean;
protectFromDownload?: boolean;
slug?: string;
photoId?: number;
requiresToken?: boolean;
secureUrlTemplate?: string;
downloadUrlTemplate?: string;
onProtectionViolation?: (violationType: string) => void;
watermarkText?: string;
overlayProtection?: boolean;
fragmentGrid?: boolean;
scrambleFragments?: boolean;
useCanvasRendering?: boolean;
blockKeyboardShortcuts?: boolean;
detectPrintScreen?: boolean;
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
} }
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -15,8 +31,46 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
alt, alt,
useWatermark = false, useWatermark = false,
isGallery = false, isGallery = false,
protectFromDownload,
slug,
photoId,
requiresToken,
secureUrlTemplate,
downloadUrlTemplate,
onProtectionViolation,
watermarkText,
overlayProtection,
fragmentGrid,
scrambleFragments,
useCanvasRendering,
blockKeyboardShortcuts,
detectPrintScreen,
detectDevTools,
protectionLevel,
useEnhancedProtection,
...props ...props
}) => { }) => {
const unusedProps = {
protectFromDownload,
slug,
photoId,
requiresToken,
secureUrlTemplate,
downloadUrlTemplate,
onProtectionViolation,
watermarkText,
overlayProtection,
fragmentGrid,
scrambleFragments,
useCanvasRendering,
blockKeyboardShortcuts,
detectPrintScreen,
detectDevTools,
protectionLevel,
useEnhancedProtection
};
void unusedProps;
const [imageSrc, setImageSrc] = useState<string>(''); const [imageSrc, setImageSrc] = useState<string>('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -25,33 +79,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
let objectUrl: string | null = null; let objectUrl: string | null = null;
// Determine which token to use based on context // Determine which token to use based on context
let token: string | undefined;
if (isGallery) {
// For gallery images, get the gallery-specific token
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined;
}
} else {
// For admin images, use the admin token
token = getAuthToken(true);
}
if (!src) { if (!src) {
setImageSrc(fallbackSrc || ''); setImageSrc(fallbackSrc || '');
setIsLoading(false); setIsLoading(false);
return; return;
} }
if (!token) {
// No auth token - use fallback
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
setError(false); setError(false);
@@ -71,9 +104,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Fetch authenticated image // Fetch authenticated image
const response = await fetch(fullImageUrl, { const response = await fetch(fullImageUrl, {
headers: { credentials: 'include'
'Authorization': `Bearer ${token}`
}
}); });
if (!response.ok) { if (!response.ok) {
@@ -119,4 +150,4 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
} }
return <img src={imageSrc} alt={alt} {...props} />; return <img src={imageSrc} alt={alt} {...props} />;
}; };
@@ -183,7 +183,7 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="sm"
onClick={() => selectPassword(password)} onClick={() => selectPassword(password)}
> >
{t('passwordGenerator.use')} {t('passwordGenerator.use')}
@@ -206,4 +206,4 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
); );
}; };
export default PasswordGenerator; export default PasswordGenerator;
@@ -1,6 +1,12 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { ProtectionLevel } from '../../hooks/useImageProtection'; import { ProtectionLevel } from '../../hooks/useImageProtection';
type VendorStyle = CSSStyleDeclaration & {
webkitUserSelect?: string;
webkitTouchCallout?: string;
webkitUserDrag?: string;
};
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> { interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
src: string; src: string;
alt: string; alt: string;
@@ -31,7 +37,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@@ -201,9 +206,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
// Render normal image - ensure image is valid before drawing // Render normal image - ensure image is valid before drawing
if (img.naturalWidth > 0 && img.naturalHeight > 0) { if (img.naturalWidth > 0 && img.naturalHeight > 0) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Verify the image was drawn by checking a pixel
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
} }
} }
@@ -288,11 +290,12 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
} }
// Apply CSS protection // Apply CSS protection
canvas.style.userSelect = 'none'; const canvasStyle = canvas.style as VendorStyle;
canvas.style.webkitUserSelect = 'none'; canvasStyle.userSelect = 'none';
canvas.style.webkitTouchCallout = 'none'; canvasStyle.webkitUserSelect = 'none';
canvas.style.webkitUserDrag = 'none'; canvasStyle.webkitTouchCallout = 'none';
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto'; canvasStyle.webkitUserDrag = 'none';
canvasStyle.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
return () => { return () => {
canvas.removeEventListener('contextmenu', handleContextMenu); canvas.removeEventListener('contextmenu', handleContextMenu);
@@ -402,4 +405,4 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
}} }}
/> />
); );
}; };
@@ -102,7 +102,7 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
/> />
)} )}
<style jsx>{` <style>{`
@keyframes shrink { @keyframes shrink {
from { width: 100%; } from { width: 100%; }
to { width: 0%; } to { width: 0%; }
@@ -110,4 +110,4 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
`}</style> `}</style>
</div> </div>
); );
}; };
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Heart, Star } from 'lucide-react'; import { Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
import { Button } from '../common'; import { Button } from '../common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export type FilterType = 'all' | 'liked' | 'favorited'; export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
interface GalleryFilterProps { interface GalleryFilterProps {
currentFilter: FilterType; currentFilter: FilterType;
@@ -11,8 +11,10 @@ interface GalleryFilterProps {
feedbackEnabled: boolean; feedbackEnabled: boolean;
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
ratedCount?: number;
className?: string; className?: string;
isMobile?: boolean; isMobile?: boolean;
variant?: 'default' | 'compact';
} }
export const GalleryFilter: React.FC<GalleryFilterProps> = ({ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
@@ -21,8 +23,10 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
feedbackEnabled, feedbackEnabled,
likeCount = 0, likeCount = 0,
favoriteCount = 0, favoriteCount = 0,
ratedCount = 0,
className = '', className = '',
isMobile = false isMobile = false,
variant = 'default'
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,6 +34,66 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
return null; return null;
} }
// Compact icon-only vertical variant (used in sidebar and tight spaces)
if (variant === 'compact') {
return (
<div className={`${className}`}>
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-700 whitespace-nowrap">
{t('gallery.feedbackFilter', 'Feedback Filter')}
</span>
<div className="flex items-center gap-1">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.all', 'All')}
>
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-current"><path d="M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 8v-8h8v8h-8z"/></svg>
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('feedback.likes', 'Likes')}
>
<Heart className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.favorited', 'Saved')}
>
<Bookmark className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.rated', 'Rated')}
>
<Star className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.commented', 'Commented')}
>
<MessageSquare className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
);
}
return ( return (
<div className={`${className}`}> <div className={`${className}`}>
{/* Mobile-optimized vertical layout */} {/* Mobile-optimized vertical layout */}
@@ -57,15 +121,25 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Heart className="w-3 h-3" /> <Heart className="w-3 h-3" />
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span> <span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1" className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
>
<Bookmark className="w-3 h-3" />
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorited', 'Saved')}</span>
</Button>
<Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('rated')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
> >
<Star className="w-3 h-3" /> <Star className="w-3 h-3" />
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorites', 'Favorites')}</span> <span>{ratedCount > 0 ? ratedCount : t('gallery.rated', 'Rated')}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -99,24 +173,49 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</span> </span>
)} )}
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1" className="text-xs sm:text-sm flex items-center gap-1"
> >
<Star className="w-3 h-3 sm:w-4 sm:h-4" /> <Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.favorited', 'Favorites')}</span> <span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
{favoriteCount > 0 && ( {favoriteCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded"> <span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{favoriteCount} {favoriteCount}
</span> </span>
)} )}
</Button> </Button>
<Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('rated')}
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.rated', 'Rated')}</span>
{ratedCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{ratedCount}
</span>
)}
</Button>
<Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('commented')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.commented', 'Commented')}</span>
</Button>
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
}; };
@@ -35,7 +35,6 @@ interface GalleryLayoutProps {
showDownloadAll?: boolean; showDownloadAll?: boolean;
onDownloadAll?: () => void; onDownloadAll?: () => void;
isDownloading?: boolean; isDownloading?: boolean;
isExpired?: boolean;
headerExtra?: React.ReactNode; headerExtra?: React.ReactNode;
menuButton?: React.ReactNode; menuButton?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
@@ -49,7 +48,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
showDownloadAll = false, showDownloadAll = false,
onDownloadAll, onDownloadAll,
isDownloading = false, isDownloading = false,
isExpired = false,
headerExtra, headerExtra,
menuButton, menuButton,
children, children,
@@ -63,22 +61,28 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const headingFontFamily = theme.headingFontFamily || fontFamily; const headingFontFamily = theme.headingFontFamily || fontFamily;
// Calculate logo size classes based on settings // Calculate logo size classes based on settings
const getLogoSizeClass = (context: 'header' | 'hero') => { const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
const size = brandingSettings?.logo_size || 'medium'; const size = brandingSettings?.logo_size || 'medium';
const maxHeight = brandingSettings?.logo_max_height || 48; const maxHeight = brandingSettings?.logo_max_height || 48;
if (size === 'custom') { if (size === 'custom') {
return { maxHeight: `${maxHeight}px`, height: 'auto' }; return {
className: '',
style: { maxHeight: `${maxHeight}px`, height: 'auto' }
};
} }
const sizeMap = { const sizeMap: Record<'small' | 'medium' | 'large' | 'xlarge', string> = {
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16', 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', 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', 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' xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
}; };
return sizeMap[size] || sizeMap.medium; return {
className: sizeMap[size as keyof typeof sizeMap] || sizeMap.medium,
style: undefined
};
}; };
// Determine logo position classes // Determine logo position classes
@@ -108,6 +112,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text'; const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
return displayMode !== 'logo_only'; return displayMode !== 'logo_only';
}; };
const headerLogoSize = getLogoDimensions('header');
const heroLogoSize = getLogoDimensions('hero');
return ( return (
<div className="min-h-screen bg-neutral-50"> <div className="min-h-screen bg-neutral-50">
@@ -183,8 +190,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`} className={`${headerLogoSize.className} w-auto object-contain`}
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined} style={headerLogoSize.style}
/> />
{shouldShowCompanyName() && brandingSettings?.company_name && ( {shouldShowCompanyName() && brandingSettings?.company_name && (
<span className="hidden sm:inline text-lg font-semibold text-neutral-900"> <span className="hidden sm:inline text-lg font-semibold text-neutral-900">
@@ -353,11 +360,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`} className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={typeof getLogoSizeClass('hero') === 'object' ? style={{
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } : ...(heroLogoSize.style || {}),
{ 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 && ( {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)' }}> <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)' }}>
@@ -460,4 +467,4 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
); );
}; };
GalleryLayout.displayName = 'GalleryLayout'; GalleryLayout.displayName = 'GalleryLayout';
@@ -21,7 +21,6 @@ interface GallerySidebarProps {
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
isDownloading: boolean; isDownloading: boolean;
isExpired?: boolean;
allowDownloads?: boolean; allowDownloads?: boolean;
photoCounts?: Record<number, number>; photoCounts?: Record<number, number>;
totalPhotos: number; totalPhotos: number;
@@ -34,6 +33,7 @@ interface GallerySidebarProps {
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
ratedCount?: number;
} }
export const GallerySidebar: React.FC<GallerySidebarProps> = ({ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
@@ -52,7 +52,6 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
isDownloading, isDownloading,
isExpired = false,
allowDownloads = true, allowDownloads = true,
photoCounts = {}, photoCounts = {},
totalPhotos, totalPhotos,
@@ -64,7 +63,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
filterType = 'all', filterType = 'all',
onFilterChange, onFilterChange,
likeCount = 0, likeCount = 0,
favoriteCount = 0 favoriteCount = 0,
ratedCount = 0
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -114,7 +114,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<div <div
ref={sidebarRef} ref={sidebarRef}
className={` className={`
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
${isMobile ? 'w-full max-w-sm' : 'w-80'} ${isMobile ? 'w-full max-w-sm' : 'w-80'}
${isOpen ? 'translate-x-0' : '-translate-x-full'} ${isOpen ? 'translate-x-0' : '-translate-x-full'}
`} `}
@@ -224,7 +224,9 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
likeCount={likeCount} likeCount={likeCount}
favoriteCount={favoriteCount} favoriteCount={favoriteCount}
ratedCount={ratedCount}
className="w-full" className="w-full"
variant="compact"
/> />
</div> </div>
)} )}
@@ -328,4 +330,4 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</> </>
); );
}; };
@@ -20,8 +20,8 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react'; import { Upload, Menu } from 'lucide-react';
import { galleryService } from '../../services/gallery.service'; import { galleryService } from '../../services/gallery.service';
import { feedbackService } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import type { Photo } from '../../types';
interface GalleryViewProps { interface GalleryViewProps {
slug: string; slug: string;
@@ -58,6 +58,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
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 [filterType, setFilterType] = useState<FilterType>('all');
const [guestId, setGuestId] = useState<string>(''); const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
// Generate a unique guest ID for this session // Generate a unique guest ID for this session
useEffect(() => { useEffect(() => {
@@ -167,6 +168,23 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
}, [settingsData]); }, [settingsData]);
// Determine a stable hero photo from the initial (unfiltered) load
useEffect(() => {
if (!staticHeroPhoto && data?.photos && filterType === 'all') {
let hero: Photo | null = null;
const heroId = data?.event?.hero_photo_id || null;
if (heroId) {
hero = data.photos.find(p => p.id === heroId) || null;
}
if (!hero && data.photos.length > 0) {
hero = data.photos[0];
}
if (hero) {
setStaticHeroPhoto(hero);
}
}
}, [data?.photos, data?.event?.hero_photo_id, filterType, staticHeroPhoto]);
// Apply theme when settings are loaded // Apply theme when settings are loaded
useEffect(() => { useEffect(() => {
if (settingsData && data?.event) { if (settingsData && data?.event) {
@@ -247,6 +265,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
); );
} }
// Apply feedback filter
switch (filterType) {
case 'liked':
photos = photos.filter(photo => (photo.like_count || 0) > 0);
break;
case 'favorited':
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
break;
case 'rated':
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
break;
case 'commented':
photos = photos.filter(photo => (photo.comment_count || 0) > 0);
break;
default:
break;
}
// Apply sorting // Apply sorting
photos.sort((a, b) => { photos.sort((a, b) => {
switch (sortBy) { switch (sortBy) {
@@ -279,7 +315,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
return photos; return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]); }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
[data?.photos]
);
const favoriteCount = useMemo(
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
[data?.photos]
);
const ratedCount = useMemo(
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
[data?.photos]
);
// Check if downloads are allowed (both event setting and not expired) // Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true); const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
@@ -428,7 +479,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onDownloadAll={handleDownloadAll} onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected} onDownloadSelected={handleDownloadSelected}
isDownloading={downloadAllMutation.isPending} isDownloading={downloadAllMutation.isPending}
isExpired={isExpired}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
photoCounts={photoCounts} photoCounts={photoCounts}
totalPhotos={data?.photos.length || 0} totalPhotos={data?.photos.length || 0}
@@ -439,8 +489,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
filterType={filterType} filterType={filterType}
onFilterChange={setFilterType} onFilterChange={setFilterType}
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0} likeCount={likeCount}
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0} favoriteCount={favoriteCount}
ratedCount={ratedCount}
/> />
) : null} ) : null}
@@ -452,7 +503,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
showDownloadAll={!showSidebar && allowDownloads} showDownloadAll={!showSidebar && allowDownloads}
onDownloadAll={handleDownloadAll} onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending} isDownloading={downloadAllMutation.isPending}
isExpired={isExpired}
menuButton={showSidebar ? ( menuButton={showSidebar ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -531,8 +581,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
currentFilter={filterType} currentFilter={filterType}
onFilterChange={setFilterType} 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}
@@ -543,7 +591,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
photos={filteredPhotos} photos={filteredPhotos}
slug={slug} slug={slug}
categoryId={selectedCategoryId} categoryId={selectedCategoryId}
onFeedbackChange={() => refetch()}
heroPhotoOverride={staticHeroPhoto}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
feedbackOptions={{
allowLikes: !!feedbackSettings?.allow_likes,
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos} onSelectionChange={setSelectedPhotos}
@@ -575,4 +632,4 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</GalleryLayout> </GalleryLayout>
</> </>
); );
}; };
@@ -147,7 +147,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
value={guestName} value={guestName}
onChange={(e) => setGuestName(e.target.value)} onChange={(e) => setGuestName(e.target.value)}
error={errors.guest_name} error={errors.guest_name}
size="sm"
/> />
<Input <Input
type="email" type="email"
@@ -155,7 +154,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
value={guestEmail} value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)} onChange={(e) => setGuestEmail(e.target.value)}
error={errors.guest_email} error={errors.guest_email}
size="sm"
/> />
</div> </div>
)} )}
@@ -247,4 +245,4 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
)} )}
</div> </div>
); );
}; };
@@ -73,7 +73,10 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
if (requireNameEmail && !savedIdentity) { if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true); setShowIdentityModal(true);
} else { } else {
submitFavoriteMutation.mutate(savedIdentity || {}); const identityPayload = savedIdentity
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
: {};
submitFavoriteMutation.mutate(identityPayload);
} }
}; };
@@ -116,4 +119,4 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
/> />
</> </>
); );
}; };
@@ -3,10 +3,9 @@ import { useQuery } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating'; import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes'; import { PhotoLikes } from './PhotoLikes';
import { PhotoComments } from './PhotoComments';
import { PhotoFavorites } from './PhotoFavorites'; import { PhotoFavorites } from './PhotoFavorites';
import { PhotoComments } from './PhotoComments';
import { Skeleton } from '../common'; import { Skeleton } from '../common';
import type { FeedbackSettings } from '../../services/feedback.service';
interface PhotoFeedbackProps { interface PhotoFeedbackProps {
photoId: string; photoId: string;
@@ -34,7 +33,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
}); });
// Fetch feedback data for the photo // Fetch feedback data for the photo
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({ const { data: feedbackData } = useQuery({
queryKey: ['photo-feedback', gallerySlug, photoId], queryKey: ['photo-feedback', gallerySlug, photoId],
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId), queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
enabled: !!settings?.feedback_enabled, enabled: !!settings?.feedback_enabled,
@@ -43,18 +42,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
// Local state for optimistic updates // Local state for optimistic updates
const [currentRating, setCurrentRating] = useState(0); const [currentRating, setCurrentRating] = useState(0);
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [likeCount, setLikeCount] = useState(0); const [likeCount, setLikeCount] = useState(0);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0); const [favoriteCount, setFavoriteCount] = useState(0);
// Update local state when data loads // Update local state when data loads
useEffect(() => { useEffect(() => {
if (feedbackData) { if (feedbackData) {
setCurrentRating(feedbackData.my_feedback.rating || 0); setCurrentRating(feedbackData.my_feedback.rating || 0);
setIsLiked(feedbackData.my_feedback.liked); setIsLiked(Boolean(feedbackData.my_feedback.liked));
setIsFavorited(feedbackData.my_feedback.favorited); setLikeCount(Number(feedbackData.summary.like_count) || 0);
setLikeCount(feedbackData.summary.like_count); setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
setFavoriteCount(feedbackData.summary.favorite_count); setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
} }
}, [feedbackData]); }, [feedbackData]);
@@ -89,7 +88,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
return null; return null;
} }
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes || const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments || settings.allow_favorites; settings.allow_comments || settings.allow_favorites;
if (!hasAnyFeedbackType) { if (!hasAnyFeedbackType) {
@@ -114,7 +113,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
{/* Action Buttons */} {/* Action Buttons */}
{(settings.allow_likes || settings.allow_favorites) && ( {(settings.allow_likes || settings.allow_favorites) && (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{settings.allow_likes && ( {settings.allow_likes && (
<PhotoLikes <PhotoLikes
photoId={photoId} photoId={photoId}
@@ -126,6 +125,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
onLikeChange={handleLikeChange} onLikeChange={handleLikeChange}
/> />
)} )}
{settings.allow_favorites && ( {settings.allow_favorites && (
<PhotoFavorites <PhotoFavorites
photoId={photoId} photoId={photoId}
@@ -158,4 +158,4 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
)} )}
</div> </div>
); );
}; };
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Search, SortAsc, Grid, Heart, Star } from 'lucide-react'; import { Search, SortAsc, Grid, Heart, Star, MessageSquare, Bookmark } 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'; import type { FilterType } from './GalleryFilter';
@@ -32,8 +32,6 @@ interface PhotoFilterBarProps {
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
currentFilter?: FilterType; currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
likeCount?: number;
favoriteCount?: number;
} }
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -49,12 +47,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
feedbackEnabled = false, feedbackEnabled = false,
currentFilter = 'all', currentFilter = 'all',
onFilterChange, onFilterChange,
likeCount = 0,
favoriteCount = 0,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Search and Sort */} {/* Search and Sort */}
@@ -143,6 +138,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
{/* Categories Row */} {/* Categories Row */}
{categories && categories.length > 0 && ( {categories && categories.length > 0 && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3"> <div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
{/* Categories: keep in a horizontal scroll container */}
<div className="w-full overflow-x-auto pb-2 lg:pb-0"> <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
@@ -170,81 +166,122 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</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>
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
{feedbackEnabled && onFilterChange && (
<div className="hidden lg:flex items-center gap-2 mx-2 flex-shrink-0">
<span className="text-sm text-neutral-600 whitespace-nowrap">
{t('gallery.feedbackFilter', 'Feedback Filter')}
</span>
<div className="flex items-center gap-1">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.all', 'All')}
>
<Grid className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('feedback.likes', 'Likes')}
>
<Heart className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.favorited', 'Saved')}
>
<Bookmark className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.rated', 'Rated')}
>
<Star className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.commented', 'Commented')}
>
<MessageSquare className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto"> <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>
)} )}
{/* Mobile/Tablet: Feedback Filter below categories */} {/* Mobile/Tablet: compact horizontal icons with headline below categories */}
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
<div className="flex lg:hidden items-center gap-2"> <div className="flex lg:hidden items-center gap-2">
<span className="text-xs text-neutral-600">{t('gallery.feedbackFilter')}:</span> <span className="text-xs text-neutral-600 whitespace-nowrap">
<div className="flex gap-1 flex-1"> {t('gallery.feedbackFilter', 'Feedback Filter')}
</span>
<div className="flex items-center gap-1">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="text-xs flex-1" className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.all', 'All')}
> >
{t('gallery.all')} <Grid className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="text-xs flex-1 flex items-center justify-center gap-1" className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('feedback.likes', 'Likes')}
> >
<Heart className="w-3 h-3" /> <Heart className="w-3.5 h-3.5" />
{likeCount > 0 && <span>{likeCount}</span>}
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 flex items-center justify-center gap-1" className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.favorited', 'Saved')}
> >
<Star className="w-3 h-3" /> <Bookmark className="w-3.5 h-3.5" />
{favoriteCount > 0 && <span>{favoriteCount}</span>} </Button>
<Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.rated', 'Rated')}
>
<Star className="w-3.5 h-3.5" />
</Button>
<Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center"
aria-label={t('gallery.commented', 'Commented')}
>
<MessageSquare className="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -254,4 +291,4 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
); );
}; };
PhotoFilterBar.displayName = 'PhotoFilterBar'; PhotoFilterBar.displayName = 'PhotoFilterBar';
+21 -36
View File
@@ -95,35 +95,17 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
const handleDownloadSelected = async () => { const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return; if (selectedPhotos.size === 0) return;
const ids = Array.from(selectedPhotos);
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id)); toastify.info(t('gallery.downloading', { count: ids.length }));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
// Download failed - error handled by UI
return null;
})
);
try { try {
await Promise.all(downloadPromises); await galleryService.downloadSelectedPhotos(slug, ids);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size })); analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) { } catch (error) {
toastify.error(t('gallery.downloadError')); toastify.error(t('gallery.downloadError'));
} finally {
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} }
}; };
@@ -209,6 +191,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
slug={slug} slug={slug}
feedbackEnabled={feedbackEnabled}
/> />
))} ))}
</div> </div>
@@ -240,6 +223,7 @@ interface PhotoThumbnailProps {
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
slug: string; // Add slug as required prop slug: string; // Add slug as required prop
feedbackEnabled?: boolean;
} }
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
@@ -251,7 +235,8 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
slug slug,
feedbackEnabled = false
}) => { }) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
@@ -298,25 +283,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
/> />
{/* Feedback Indicators */} {/* Feedback Indicators */}
{feedbackEnabled && (photo.has_feedback || photo.average_rating > 0 || photo.comment_count > 0) && ( {feedbackEnabled && (photo.has_feedback || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{photo.comment_count > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
{photo.average_rating > 0 && ( {(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" /> <Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span> <span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Overlay on hover/tap - Always visible on mobile for better UX */} {/* Overlay on hover/tap - Always visible on mobile for better UX */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && ( {!isSelectionMode && (
<> <>
<button <button
@@ -365,4 +350,4 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
)} )}
</div> </div>
); );
}; };
@@ -25,6 +25,9 @@ interface PhotoGridWithLayoutsProps {
photos: Photo[]; photos: Photo[];
slug: string; slug: string;
categoryId?: number | null; categoryId?: number | null;
// When provided, the hero layout will use this photo
// instead of deriving from the filtered photo list.
heroPhotoOverride?: Photo | null;
isSelectionMode?: boolean; isSelectionMode?: boolean;
selectedPhotos?: Set<number>; selectedPhotos?: Set<number>;
onSelectionChange?: (photos: Set<number>) => void; onSelectionChange?: (photos: Set<number>) => void;
@@ -38,15 +41,26 @@ interface PhotoGridWithLayoutsProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
allowRatings?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
onFeedbackChange?: () => void;
} }
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos, photos,
slug, slug,
categoryId, categoryId,
heroPhotoOverride,
isSelectionMode: parentSelectionMode, isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos, selectedPhotos: parentSelectedPhotos,
feedbackEnabled, feedbackEnabled,
feedbackOptions,
onFeedbackChange,
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
@@ -61,6 +75,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useTheme(); const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [openFeedbackInitially, setOpenFeedbackInitially] = useState<boolean>(false);
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set()); const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
const [localSelectionMode, setLocalSelectionMode] = useState(false); const [localSelectionMode, setLocalSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
@@ -77,10 +92,24 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
}, [categoryId]); }, [categoryId]);
const handlePhotoClick = (index: number) => { const handlePhotoClick = (index: number) => {
setOpenFeedbackInitially(false);
setSelectedPhotoIndex(index);
};
const handleOpenWithFeedback = (index: number) => {
setOpenFeedbackInitially(true);
setSelectedPhotoIndex(index); setSelectedPhotoIndex(index);
}; };
const handlePhotoSelect = (photoId: number) => { const handlePhotoSelect = (photoId: number) => {
// Auto-enable selection mode when selecting via checkbox
if (!isSelectionMode) {
if (parentToggleSelectionMode) {
parentToggleSelectionMode();
} else {
setLocalSelectionMode(true);
}
}
const newSelected = new Set(selectedPhotos); const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) { if (newSelected.has(photoId)) {
newSelected.delete(photoId); newSelected.delete(photoId);
@@ -114,39 +143,21 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const handleDownloadSelected = async () => { const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return; if (selectedPhotos.size === 0) return;
const ids = Array.from(selectedPhotos);
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id)); toastify.info(t('gallery.downloading', { count: ids.length }));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
// Download failed - error handled by UI
return null;
})
);
try { try {
await Promise.all(downloadPromises); await galleryService.downloadSelectedPhotos(slug, ids);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size })); analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
} catch (error) {
// Track bulk download toastify.error(t('gallery.downloadError'));
analyticsService.trackGalleryEvent('bulk_download', { } finally {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set()); setSelectedPhotos(new Set());
if (parentToggleSelectionMode) { if (parentToggleSelectionMode) {
parentToggleSelectionMode(); parentToggleSelectionMode();
} else { } else {
setLocalSelectionMode(false); setLocalSelectionMode(false);
} }
} catch (error) {
toastify.error(t('gallery.downloadError'));
} }
}; };
@@ -166,7 +177,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos, photos,
slug, slug,
onPhotoClick: handlePhotoClick, onPhotoClick: handlePhotoClick,
onOpenPhotoWithFeedback: handleOpenWithFeedback,
onFeedbackChange: onFeedbackChange,
onDownload: handleDownload, onDownload: handleDownload,
heroPhotoOverride,
selectedPhotos, selectedPhotos,
allowDownloads, allowDownloads,
protectionLevel, protectionLevel,
@@ -178,6 +192,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
eventDate, eventDate,
expiresAt, expiresAt,
feedbackEnabled, feedbackEnabled,
feedbackOptions,
}; };
let LayoutComponent; let LayoutComponent;
@@ -275,8 +290,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
/> />
)} )}
</> </>
); );
}; };
+200 -20
View File
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery'; import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common'; import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback'; import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
interface PhotoLightboxProps { interface PhotoLightboxProps {
photos: Photo[]; photos: Photo[];
@@ -15,6 +17,8 @@ interface PhotoLightboxProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
initialShowFeedback?: boolean;
onFeedbackChange?: () => void;
} }
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -26,6 +30,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
initialShowFeedback = false,
onFeedbackChange,
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
@@ -33,7 +39,28 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchDistance, setTouchDistance] = useState<number | null>(null); const [touchDistance, setTouchDistance] = useState<number | null>(null);
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
const [feedbackSettings, setFeedbackSettings] = useState<{
feedback_enabled?: boolean;
allow_likes?: boolean;
allow_ratings?: boolean;
require_name_email?: boolean;
} | null>(null);
const [myLiked, setMyLiked] = useState<boolean>(false);
const [myRating, setMyRating] = useState<number>(0);
const [likeCount, setLikeCount] = useState<number>(0);
const [avgRating, setAvgRating] = useState<number>(0);
const [totalRatings, setTotalRatings] = useState<number>(0);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
@@ -111,6 +138,81 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}; };
}, [currentIndex]); }, [currentIndex]);
// Load feedback settings once
useEffect(() => {
let mounted = true;
(async () => {
try {
const settings = await feedbackService.getGalleryFeedbackSettings(slug);
if (mounted) setFeedbackSettings(settings as any);
} catch {
// ignore
}
})();
return () => { mounted = false; };
}, [slug]);
// Load my feedback for the current photo
useEffect(() => {
let mounted = true;
(async () => {
try {
if (!feedbackSettings?.feedback_enabled) return;
const data = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
if (!mounted) return;
setMyLiked(!!data.my_feedback.liked);
setMyRating(data.my_feedback.rating || 0);
setLikeCount(Number(data.summary?.like_count) || 0);
setAvgRating(Number(data.summary?.average_rating) || 0);
setTotalRatings(Number(data.summary?.total_ratings) || 0);
} catch {
// ignore
}
})();
return () => { mounted = false; };
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
const submitLike = async () => {
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'like' });
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setMyLiked(prev => {
const next = !prev;
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
return next;
});
};
const submitRating = async (value: number) => {
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'rating', rating: value });
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'rating',
rating: value,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
setMyRating(value);
// Refresh current summary to reflect average and totals
try {
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
setAvgRating(Number(fresh.summary?.average_rating) || 0);
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
} catch {}
};
const goToPrevious = () => { const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
resetZoom(); resetZoom();
@@ -211,13 +313,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` : `fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
'fixed inset-0 bg-black z-50 flex items-center justify-center'; 'fixed inset-0 bg-black z-50 flex items-center justify-center';
const desktopFeedbackWidth = 416; // 26rem; keep in sync with panel width
const isDesktopFeedback = showFeedback && !isSmallScreen;
return ( return (
<div className={lightboxClass}> <div className={lightboxClass}>
{/* Close button */} {/* Close button */}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20" className="absolute top-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
aria-label="Close" aria-label="Close"
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : '1rem' }}
> >
<X className="w-6 h-6 text-white" /> <X className="w-6 h-6 text-white" />
</button> </button>
@@ -231,16 +337,22 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<ChevronLeft className="w-6 h-6 text-white" /> <ChevronLeft className="w-6 h-6 text-white" />
</button> </button>
<button {!showFeedback || !isSmallScreen ? (
onClick={goToNext} <button
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20" onClick={goToNext}
aria-label="Next photo" className="absolute top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
> aria-label="Next photo"
<ChevronRight className="w-6 h-6 text-white" /> style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : '1rem' }}
</button> >
<ChevronRight className="w-6 h-6 text-white" />
</button>
) : null}
{/* Bottom toolbar */} {/* Bottom toolbar */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20"> <div
className="absolute bottom-0 left-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20"
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0 }}
>
<div className="max-w-4xl mx-auto flex items-center justify-between"> <div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-white"> <div className="text-white">
<p className="text-sm opacity-75"> <p className="text-sm opacity-75">
@@ -280,6 +392,39 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<Download className="w-5 h-5 text-white" /> <Download className="w-5 h-5 text-white" />
</button> </button>
)} )}
{/* Inline Like */}
{feedbackEnabled && feedbackSettings?.allow_likes && (
<div className="flex items-center gap-1">
<button
onClick={submitLike}
className={`p-2 rounded-full transition-colors ${myLiked ? 'bg-red-500/80 hover:bg-red-500' : 'bg-white/10 hover:bg-white/20'}`}
aria-label={myLiked ? 'Unlike photo' : 'Like photo'}
title={myLiked ? 'Unlike' : 'Like'}
>
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
</button>
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
</div>
)}
{/* Inline Rating */}
{feedbackEnabled && feedbackSettings?.allow_ratings && (
<div className="flex items-center gap-1 ml-1" aria-label="Rate photo">
{[1,2,3,4,5].map((i) => (
<button
key={i}
onClick={() => submitRating(i)}
className="p-1"
aria-label={`Rate ${i} star${i>1?'s':''}`}
title={`Rate ${i}`}
>
<Star className={`w-5 h-5 ${myRating >= i ? 'text-yellow-400 fill-yellow-400' : 'text-white/70'}`} />
</button>
))}
<span className="text-white/90 text-xs ml-2 select-none">{avgRating.toFixed(1)} ({totalRatings})</span>
</div>
)}
{/* Feedback button with indicator */} {/* Feedback button with indicator */}
{feedbackEnabled && ( {feedbackEnabled && (
@@ -289,12 +434,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}} }}
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"
aria-label="Toggle feedback" aria-label="Toggle feedback"
title={`Photo feedback${currentPhoto.comment_count > 0 ? ` (${currentPhoto.comment_count} comments)` : ''}`} title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
> >
<MessageSquare className="w-5 h-5 text-white" /> <MessageSquare className="w-5 h-5 text-white" />
{(currentPhoto.comment_count > 0 || currentPhoto.average_rating > 0) && ( {((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"> <span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{currentPhoto.comment_count > 0 ? currentPhoto.comment_count : '★'} {(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
</span> </span>
)} )}
</button> </button>
@@ -305,7 +450,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Image container */} {/* Image container */}
<div <div
className="absolute inset-0 flex items-center justify-center z-0" className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={handleImageClick} onClick={handleImageClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@@ -314,11 +459,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} style={{
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
> >
<AuthenticatedImage <AuthenticatedImage
src={currentPhoto.url} src={currentPhoto.url}
alt={currentPhoto.filename} alt={currentPhoto.filename}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none" className="max-w-full max-h-full object-contain select-none"
style={{ style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`, transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
@@ -369,7 +518,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Feedback Panel */} {/* Feedback Panel */}
{showFeedback && ( {showFeedback && (
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-96 lg:w-[28rem] bg-white shadow-xl z-20 overflow-y-auto"> <div className="absolute right-0 top-0 bottom-0 w-full sm:w-[26rem] bg-white shadow-xl z-20 overflow-y-auto flex flex-col border-l border-neutral-200">
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between"> <div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3> <h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
<button <button
@@ -380,16 +529,47 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-4"> <div className="p-4 flex-1 overflow-y-auto">
<PhotoFeedback <PhotoFeedback
photoId={currentPhoto.id} photoId={String(currentPhoto.id)}
gallerySlug={slug} gallerySlug={slug}
showComments={true} showComments={true}
className="space-y-4" className="space-y-4"
onFeedbackUpdate={() => {
if (onFeedbackChange) onFeedbackChange();
}}
/> />
</div> </div>
</div> </div>
)} )}
{/* Identity Modal for required name/email */}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction?.type === 'like') {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'like',
guest_name: name,
guest_email: email,
});
setMyLiked(true);
} else if (pendingAction?.type === 'rating' && pendingAction.rating) {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'rating',
rating: pendingAction.rating,
guest_name: name,
guest_email: email,
});
setMyRating(pendingAction.rating);
}
setPendingAction(null);
}}
feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'}
/>
</div> </div>
); );
}; };
@@ -73,7 +73,10 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
if (requireNameEmail && !savedIdentity) { if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true); setShowIdentityModal(true);
} else { } else {
submitLikeMutation.mutate(savedIdentity || {}); const identityPayload = savedIdentity
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
: {};
submitLikeMutation.mutate(identityPayload);
} }
}; };
@@ -116,4 +119,4 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
/> />
</> </>
); );
}; };
-1
View File
@@ -10,4 +10,3 @@ export { PhotoFeedback } from './PhotoFeedback';
export { PhotoRating } from './PhotoRating'; export { PhotoRating } from './PhotoRating';
export { PhotoLikes } from './PhotoLikes'; export { PhotoLikes } from './PhotoLikes';
export { PhotoComments } from './PhotoComments'; export { PhotoComments } from './PhotoComments';
export { PhotoFavorites } from './PhotoFavorites';
@@ -5,6 +5,10 @@ export interface BaseGalleryLayoutProps {
photos: Photo[]; photos: Photo[];
slug: string; slug: string;
onPhotoClick: (index: number) => void; onPhotoClick: (index: number) => void;
// Optional: open the lightbox with feedback panel visible
onOpenPhotoWithFeedback?: (index: number) => void;
// Notify parent that feedback (like/favorite/rating/comment) changed
onFeedbackChange?: () => void;
onDownload: (photo: Photo, e: React.MouseEvent) => void; onDownload: (photo: Photo, e: React.MouseEvent) => void;
selectedPhotos?: Set<number>; selectedPhotos?: Set<number>;
isSelectionMode?: boolean; isSelectionMode?: boolean;
@@ -17,8 +21,15 @@ export interface BaseGalleryLayoutProps {
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
allowRatings?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
} }
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> { export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
abstract render(): React.ReactNode; abstract render(): React.ReactNode;
} }
@@ -1,16 +1,20 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, MessageSquare } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage, Button } from '../../common'; import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos, photos,
slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onDownload, onDownload,
allowDownloads = true, allowDownloads = true,
// selectedPhotos = new Set(), feedbackEnabled = false,
// isSelectionMode = false feedbackOptions
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
@@ -59,6 +63,11 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
if (photos.length === 0) return null; if (photos.length === 0) return null;
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
return ( return (
<div className="relative"> <div className="relative">
@@ -136,6 +145,44 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5" /> <Download className="w-5 h-5" />
</Button> </Button>
)} )}
{feedbackOptions?.allowLikes && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: currentPhoto.id });
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)}
>
<Heart className="w-5 h-5" />
</Button>
)}
{canQuickComment && (
<Button
variant="ghost"
size="sm"
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
className="text-white hover:bg-white/20"
title="Comment"
aria-label="Comment on photo"
>
<MessageSquare className="w-5 h-5" />
</Button>
)}
</div> </div>
</div> </div>
@@ -181,6 +228,24 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
</div> </div>
)} )}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
<style>{` <style>{`
@keyframes progress { @keyframes progress {
from { width: 0%; } from { width: 0%; }
@@ -189,4 +254,4 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
`}</style> `}</style>
</div> </div>
); );
}; };
@@ -3,6 +3,8 @@ import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-r
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
@@ -12,12 +14,26 @@ interface GridPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
animationType?: string; animationType?: string;
allowDownloads?: boolean; allowDownloads?: boolean;
slug?: string; slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowRatings?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
savedIdentity?: { name: string; email: string } | null;
onRequireIdentity?: (action: 'like', photoId: number) => void;
onQuickComment?: () => void;
onFeedbackChange?: () => void;
// Immediate UI like state and callback
liked?: boolean;
onLikeSuccess?: () => void;
} }
const GridPhoto: React.FC<GridPhotoProps> = ({ const GridPhoto: React.FC<GridPhotoProps> = ({
@@ -26,13 +42,22 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
animationType = 'fade', animationType = 'fade',
allowDownloads = true, allowDownloads = true,
slug, slug,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
feedbackEnabled = false feedbackEnabled = false,
feedbackOptions,
savedIdentity,
onRequireIdentity,
onQuickComment,
onFeedbackChange,
liked = false,
onLikeSuccess
}) => { }) => {
// handled by parent layout; kept here for type completeness but not used
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
threshold: 0.1, threshold: 0.1,
@@ -43,6 +68,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
: animationType === 'fade' : animationType === 'fade'
? 'transition-opacity duration-300' ? 'transition-opacity duration-300'
: ''; : '';
const likeCount = photo.like_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const commentCount = photo.comment_count ?? 0;
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
return ( return (
<div <div
@@ -74,12 +103,12 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
detectPrintScreen={useEnhancedProtection} detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'maximum'} detectDevTools={protectionLevel === 'maximum'}
watermarkText={useEnhancedProtection ? 'Protected' : undefined} watermarkText={useEnhancedProtection ? 'Protected' : undefined}
onProtectionViolation={(violationType) => { onProtectionViolation={(violationType: string) => {
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`); console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
}} }}
/> />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && ( {!isSelectionMode && (
<> <>
<button <button
@@ -101,44 +130,91 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
</button> </button>
)} )}
{showFeedbackActions && onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
{/* Quick feedback actions */}
{showFeedbackActions && feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
return;
}
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (err) {
// Keep optimistic state; a refresh will reconcile
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
}}
aria-label="Like photo"
aria-pressed={liked}
title="Like"
>
<Heart className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
</> </>
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{isSelected && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{/* Feedback Indicators */} {/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( {(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && (
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
{photo.comment_count > 0 && ( {(likeCount > 0 || liked) && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
</div>
)}
{photo.average_rating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
</div>
)}
{photo.like_count > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count} likes`}>
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" /> <Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.like_count}</span> </span>
</div> )}
{averageRating > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
</span>
)}
{commentCount > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
</span>
)} )}
</div> </div>
)} )}
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 left-2"> <div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage Collage
</span> </span>
@@ -156,6 +232,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos, photos,
slug, slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onFeedbackChange,
onDownload, onDownload,
selectedPhotos = new Set(), selectedPhotos = new Set(),
isSelectionMode = false, isSelectionMode = false,
@@ -163,7 +241,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
feedbackEnabled = false feedbackEnabled = false,
feedbackOptions
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {}; const gallerySettings = theme.gallerySettings || {};
@@ -171,6 +250,11 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const spacing = gallerySettings.spacing || 'normal'; const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade'; const animation = gallerySettings.photoAnimation || 'fade';
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4'; const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `grid ${spacingClass} const gridClass = `grid ${spacingClass}
@@ -187,13 +271,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => { onClick={() => onPhotoClick(index)}
if (isSelectionMode && onPhotoSelect) { onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
animationType={animation} animationType={animation}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
@@ -201,8 +280,49 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
onRequireIdentity={(action, photoId) => {
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/> />
))} ))}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
// Immediately reflect like UI
if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(pendingAction.photoId);
return next;
});
}
setPendingAction(null);
}
}}
feedbackType="like"
/>
</div> </div>
); );
}; };
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock } from 'lucide-react'; import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
@@ -8,17 +8,23 @@ import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url'; import { buildResourceUrl } from '../../../utils/url';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string; eventName?: string;
eventLogo?: string | null; eventLogo?: string | null;
eventDate?: string; eventDate?: string;
expiresAt?: string; expiresAt?: string;
// Use a static hero photo independent of current filter
heroPhotoOverride?: Photo | null;
} }
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
photos, photos,
slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onDownload, onDownload,
selectedPhotos = new Set(), selectedPhotos = new Set(),
isSelectionMode = false, isSelectionMode = false,
@@ -27,15 +33,31 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
eventLogo, eventLogo,
eventDate, eventDate,
expiresAt, expiresAt,
allowDownloads = true heroPhotoOverride,
allowDownloads = true,
feedbackEnabled = false,
feedbackOptions
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const { theme } = useTheme(); const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null); const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const gallerySettings = theme.gallerySettings || {}; const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3; const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
// If an override is provided, always use it and skip initialization logic
useEffect(() => {
if (heroPhotoOverride) {
setHeroPhoto(heroPhotoOverride);
setHasInitialized(true);
}
}, [heroPhotoOverride]);
// Reset initialization when heroImageId changes // Reset initialization when heroImageId changes
useEffect(() => { useEffect(() => {
@@ -46,29 +68,28 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
// Select hero photo (admin-selected or first photo only if gallery was empty) // Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => { useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) { if (photos.length > 0) {
const heroId = gallerySettings.heroImageId; const heroId = gallerySettings.heroImageId;
// Process hero layout with provided photos // If admin has selected a specific hero image, always use it when available
// If admin has selected a specific hero image, always use it
if (heroId) { if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId); const adminSelectedHero = photos.find(p => p.id === heroId);
// Hero photo selected by admin
if (adminSelectedHero) { if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero); setHeroPhoto(adminSelectedHero);
setHasInitialized(true); setHasInitialized(true);
return; return;
} }
} }
// Only auto-select first photo on initial load when gallery was empty // Only auto-select first photo on initial load
// This prevents changing the hero when new photos are uploaded
if (!hasInitialized) { if (!hasInitialized) {
setHeroPhoto(photos[0]); setHeroPhoto(photos[0]);
setHasInitialized(true); setHasInitialized(true);
} }
} }
}, [photos, gallerySettings.heroImageId, hasInitialized]); }, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null; if (!heroPhoto) return null;
@@ -76,11 +97,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
const remainingPhotos = photos; const remainingPhotos = photos;
return ( return (
<>
<div className="relative -mt-6"> <div className="relative -mt-6">
{/* Hero Section */} {/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8"> <div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage <AuthenticatedImage
src={heroPhoto.url} src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename} alt={heroPhoto.filename}
className="w-full h-full object-cover" className="w-full h-full object-cover"
isGallery={true} isGallery={true}
@@ -152,13 +175,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer aspect-square" className="relative group cursor-pointer aspect-square"
onClick={() => { onClick={() => onPhotoClick(actualIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
> >
<AuthenticatedImage <AuthenticatedImage
src={photo.thumbnail_url || photo.url} src={photo.thumbnail_url || photo.url}
@@ -194,15 +211,81 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
</button> </button>
)} )}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedIds.has(photo.id)}
title="Like"
>
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{canQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</> </>
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id) || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
)}
{(photo.average_rating ?? 0) > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
</span>
)}
{(photo.comment_count ?? 0) > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-blue-600 fill-current"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -210,5 +293,23 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
})} })}
</div> </div>
</div> </div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
</>
); );
}; };
@@ -2,6 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
@@ -11,9 +13,17 @@ interface MasonryPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
allowDownloads?: boolean; allowDownloads?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
slug?: string;
feedbackOptions?: {
allowLikes?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
} }
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -22,11 +32,18 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
style, style,
allowDownloads = true, allowDownloads = true,
feedbackEnabled = false feedbackEnabled = false,
slug,
feedbackOptions,
onQuickComment
}) => { }) => {
const [imageHeight, setImageHeight] = useState<number>(200); const [imageHeight, setImageHeight] = useState<number>(200);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Generate random heights for masonry effect // Generate random heights for masonry effect
useEffect(() => { useEffect(() => {
@@ -55,24 +72,24 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
/> />
{/* Feedback Indicators */} {/* Feedback Indicators */}
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( {feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{photo.comment_count > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
{photo.average_rating > 0 && ( {(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" /> <Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span> <span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
</div> </div>
)} )}
{photo.like_count > 0 && ( {(photo.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count} likes`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count ?? 0} likes`}>
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" /> <Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.like_count}</span> <span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
</div> </div>
)} )}
</div> </div>
@@ -100,17 +117,77 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
</button> </button>
)} )}
{onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
}}
aria-label="Like photo"
title="Like"
>
<Heart className="w-5 h-5 text-neutral-800" />
</button>
)}
</> </>
)} )}
</div> </div>
{isSelectionMode && ( {/* Identity Modal */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <FeedbackIdentityModal
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> isOpen={showIdentityModal}
{isSelected && <Check className="w-4 h-4 text-white" />} onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
</div> onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 left-2"> <div className="absolute bottom-2 left-2">
@@ -125,13 +202,16 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos, photos,
slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onDownload, onDownload,
selectedPhotos = new Set(), selectedPhotos = new Set(),
isSelectionMode = false, isSelectionMode = false,
onPhotoSelect, onPhotoSelect,
allowDownloads = true, allowDownloads = true,
feedbackEnabled = false feedbackEnabled = false,
feedbackOptions
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -182,16 +262,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => { onClick={() => onPhotoClick(originalIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(originalIndex);
}
}}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
slug={slug}
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
/> />
); );
})} })}
@@ -199,4 +277,4 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
))} ))}
</div> </div>
); );
}; };
@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Download, Maximize2, Check } from 'lucide-react'; import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
// import { useTheme } from '../../../contexts/ThemeContext'; // import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
@@ -11,8 +13,17 @@ interface MosaicPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
className?: string; className?: string;
allowDownloads?: boolean; allowDownloads?: boolean;
slug?: string;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowComments?: boolean;
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
} }
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
@@ -21,10 +32,22 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
className = '', className = '',
allowDownloads = true allowDownloads = true,
slug,
feedbackEnabled = false,
feedbackOptions,
onQuickComment
}) => { }) => {
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
return ( return (
<>
<div <div
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`} className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => { onClick={(e) => {
@@ -65,18 +88,72 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
</button> </button>
)} )}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedLocal}
title="Like"
>
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{canComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment?.(); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</> </>
)} )}
</div> </div>
{isSelectionMode && ( {/* Feedback Indicators (bottom-left) */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> {((photo.like_count ?? 0) > 0 || likedLocal) && (
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> <div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
{isSelected && <Check className="w-4 h-4 text-white" />} <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
</div> <Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
</div> </div>
)} )}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 left-2"> <div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
@@ -85,17 +162,39 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
</div> </div>
)} )}
</div> </div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
</>
); );
}; };
export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos, photos,
slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onDownload, onDownload,
selectedPhotos = new Set(), selectedPhotos = new Set(),
isSelectionMode = false, isSelectionMode = false,
onPhotoSelect, onPhotoSelect,
allowDownloads = true allowDownloads = true,
feedbackEnabled = false,
feedbackOptions
}) => { }) => {
// const { theme } = useTheme(); // const { theme } = useTheme();
// const gallerySettings = theme.gallerySettings || {}; // const gallerySettings = theme.gallerySettings || {};
@@ -136,10 +235,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo0} photo={photo0}
isSelected={selectedPhotos.has(photo0.id)} isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)} onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)} onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-1" className="col-span-1"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/> />
)} )}
<div className="grid grid-rows-2 gap-2"> <div className="grid grid-rows-2 gap-2">
@@ -148,22 +252,32 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1} photo={photo1}
isSelected={selectedPhotos.has(photo1.id)} isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)} onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)} onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className="" className=""
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)} )}
{photo2 && ( {photo2 && (
<MosaicPhoto <MosaicPhoto
photo={photo2} photo={photo2}
isSelected={selectedPhotos.has(photo2.id)} isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)} onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)} onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className="" className=""
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)} )}
</div> </div>
</div> </div>
@@ -184,8 +298,13 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(currentIndex, photo.id)} onClick={() => handlePhotoClick(currentIndex, photo.id)}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className="" className=""
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(currentIndex); }}
/> />
) : null; ) : null;
})} })}
@@ -209,10 +328,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo0} photo={photo0}
isSelected={selectedPhotos.has(photo0.id)} isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)} onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)} onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-2" className="col-span-2"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/> />
)} )}
<div className="grid grid-rows-2 gap-2"> <div className="grid grid-rows-2 gap-2">
@@ -221,22 +345,32 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1} photo={photo1}
isSelected={selectedPhotos.has(photo1.id)} isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)} onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)} onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className="" className=""
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)} )}
{photo2 && ( {photo2 && (
<MosaicPhoto <MosaicPhoto
photo={photo2} photo={photo2}
isSelected={selectedPhotos.has(photo2.id)} isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)} onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)} onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className="" className=""
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)} )}
</div> </div>
</div> </div>
@@ -263,10 +397,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index, photo.id)} onClick={() => onPhotoClick(index)}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className="aspect-square" className="aspect-square"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(index); }}
/> />
); );
})} })}
@@ -282,4 +421,4 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
{renderMosaicLayout()} {renderMosaicLayout()}
</div> </div>
); );
}; };
@@ -1,24 +1,35 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { Download, Maximize2, Check, Calendar } from 'lucide-react'; import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react';
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns'; import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos, photos,
slug,
onPhotoClick, onPhotoClick,
onOpenPhotoWithFeedback,
onDownload, onDownload,
selectedPhotos = new Set(), selectedPhotos = new Set(),
isSelectionMode = false, isSelectionMode = false,
onPhotoSelect, onPhotoSelect,
allowDownloads = true allowDownloads = true,
feedbackEnabled = false,
feedbackOptions
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const gallerySettings = theme.gallerySettings || {}; const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day'; const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false; const showDates = gallerySettings.timelineShowDates !== false;
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
// Group photos by date // Group photos by date
const groupedPhotos = useMemo(() => { const groupedPhotos = useMemo(() => {
@@ -90,13 +101,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer aspect-square" className="relative group cursor-pointer aspect-square"
onClick={() => { onClick={() => onPhotoClick(actualIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
> >
<AuthenticatedImage <AuthenticatedImage
src={photo.thumbnail_url || photo.url} src={photo.thumbnail_url || photo.url}
@@ -137,17 +142,70 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
</button> </button>
)} )}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedIds.has(photo.id)}
title="Like"
>
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{canQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</> </>
)} )}
</div> </div>
{isSelectionMode && ( {((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} <Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</div> </span>
</div> </div>
)} )}
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
</div> </div>
); );
})} })}
@@ -155,6 +213,23 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
</div> </div>
))} ))}
</div> </div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
</div> </div>
); );
}; };
+64 -99
View File
@@ -1,9 +1,10 @@
import axios, { AxiosHeaders } from 'axios'; import axios, { AxiosHeaders } from 'axios';
import Cookies from 'js-cookie'; import {
getActiveGallerySlug,
// Cookie keys getGalleryToken,
export const ADMIN_TOKEN_KEY = 'admin_token'; inferGallerySlugFromLocation,
export const GALLERY_TOKEN_KEY = 'gallery_token'; resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
// Maintenance mode callback // Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -18,84 +19,70 @@ export const api = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: false, // Ensure we're not relying on cookies withCredentials: true,
}); });
// Request interceptor to add auth token // Request interceptor: drop Content-Type for FormData payloads so the browser can set boundaries
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
// Don't process if headers are already set by the component
const existingAuth = config.headers?.['Authorization'] || config.headers?.get?.('Authorization');
// If authorization is already set by the component, don't override it
if (existingAuth) {
return config;
}
// Check if it's an admin route or gallery route
const isAdminRoute = config.url?.includes('/admin');
if (isAdminRoute) {
const token = Cookies.get(ADMIN_TOKEN_KEY);
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
} else {
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const galleryIdOrSlug = galleryMatch[1];
// Remove any query parameters from the slug
const cleanIdOrSlug = galleryIdOrSlug.split('?')[0];
// Check if it's a numeric ID (for upload endpoints)
let token = null;
if (/^\d+$/.test(cleanIdOrSlug)) {
// It's an event ID - try to find the token from current page slug
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const cleanSlug = gallerySlug.split('?')[0];
token = localStorage.getItem(`gallery_token_${cleanSlug}`);
}
} else {
// It's a slug - use it directly
token = localStorage.getItem(`gallery_token_${cleanIdOrSlug}`);
}
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
// Remove any query parameters from the slug
const cleanSlug = gallerySlug.split('?')[0];
const token = localStorage.getItem(`gallery_token_${cleanSlug}`);
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
}
// Don't set Content-Type for FormData - let browser set it with boundary
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
delete config.headers?.['Content-Type']; delete config.headers?.['Content-Type'];
} }
if (typeof window !== 'undefined') {
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
const params = config.params as Record<string, unknown> | undefined;
const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null;
const rawPath = (() => {
if (!config.url) return '';
try {
if (config.url.startsWith('http://') || config.url.startsWith('https://')) {
return new URL(config.url).pathname;
}
} catch (error) {
return config.url;
}
return config.url;
})();
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname);
const isGallerySessionCheck = pathname === '/auth/session'
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
if (isGalleryEndpoint || isGallerySessionCheck) {
const fallbackSlug = getActiveGallerySlug()
|| inferGallerySlugFromLocation();
const slug = pathSlug || paramSlug || fallbackSlug;
if (slug) {
const token = getGalleryToken(slug);
if (token) {
if (!config.headers) {
config.headers = new AxiosHeaders();
}
if (config.headers instanceof AxiosHeaders) {
const existing = config.headers.get('Authorization');
if (!existing) {
config.headers.set('Authorization', `Bearer ${token}`);
}
} else {
const headersRecord = config.headers as Record<string, string | undefined>;
if (!headersRecord.Authorization) {
headersRecord.Authorization = `Bearer ${token}`;
}
}
}
}
}
}
return config; return config;
}, },
(error) => { (error) => {
@@ -110,10 +97,9 @@ api.interceptors.response.use(
// Handle maintenance mode (503) // Handle maintenance mode (503)
if (error.response?.status === 503) { if (error.response?.status === 503) {
const isAdminRoute = error.config?.url?.includes('/admin'); const isAdminRoute = error.config?.url?.includes('/admin');
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes // Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) { if (!isAdminRoute) {
if (maintenanceModeCallback) { if (maintenanceModeCallback) {
maintenanceModeCallback(true); maintenanceModeCallback(true);
} }
@@ -126,8 +112,6 @@ api.interceptors.response.use(
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
if (isAdminRoute) { if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
// Only redirect if we're not already on the admin login page // Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) { if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login'; window.location.href = '/admin/login';
@@ -144,8 +128,7 @@ api.interceptors.response.use(
// Don't clear tokens for image requests - they might just need a retry // Don't clear tokens for image requests - they might just need a retry
if (!isImageRequest && galleryMatch && galleryMatch[1]) { if (!isImageRequest && galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1]; const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`); sessionStorage.removeItem(`gallery_event_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
} }
// Don't redirect - let the component handle the auth state // Don't redirect - let the component handle the auth state
} else if (galleryMatch) { } else if (galleryMatch) {
@@ -159,21 +142,3 @@ api.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
); );
// Helper to set auth tokens
export const setAuthToken = (token: string, isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
Cookies.set(key, token, { expires: 1 }); // 1 day expiry
};
// Helper to clear auth tokens
export const clearAuthToken = (isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
Cookies.remove(key);
};
// Helper to get auth tokens
export const getAuthToken = (isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
return Cookies.get(key);
};
+29 -7
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { getAuthToken } from '../config/api'; import { api } from '../config/api';
import { authService } from '../services'; import { authService } from '../services';
import type { AdminUser } from '../types'; import type { AdminUser } from '../types';
@@ -40,15 +40,31 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
// Check if user has a valid token on mount // Check if user has a valid token on mount
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const token = getAuthToken(true); const storedUser = sessionStorage.getItem('admin_user');
if (token) { if (storedUser) {
// For now, just assume the token is valid try {
// TODO: Validate token with backend and get user info setUser(JSON.parse(storedUser));
} catch (err) {
sessionStorage.removeItem('admin_user');
}
}
const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string }>(
'/auth/session'
);
if (response.data?.valid && response.data.type === 'admin') {
setIsAuthenticated(true); setIsAuthenticated(true);
} else {
sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
} }
} catch (error) { } catch (error) {
// Auth check failed - user needs to login // Auth check failed - user needs to login
setError('Failed to check authentication'); sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -63,9 +79,11 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
setError(null); setError(null);
setIsAuthenticated(true); setIsAuthenticated(true);
setMustChangePassword(user.mustChangePassword || false); setMustChangePassword(user.mustChangePassword || false);
sessionStorage.setItem('admin_user', JSON.stringify(user));
}; };
const logout = () => { const logout = () => {
sessionStorage.removeItem('admin_user');
authService.adminLogout(); authService.adminLogout();
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
@@ -79,6 +97,10 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
...user, ...user,
mustChangePassword: false mustChangePassword: false
}); });
sessionStorage.setItem('admin_user', JSON.stringify({
...user,
mustChangePassword: false
}));
} }
}; };
@@ -98,4 +120,4 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
{children} {children}
</AdminAuthContext.Provider> </AdminAuthContext.Provider>
); );
}; };
+108 -34
View File
@@ -1,7 +1,14 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { authService } from '../services'; import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import {
clearActiveGallerySlug,
clearGalleryToken,
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
interface GalleryEvent { interface GalleryEvent {
id: number; id: number;
@@ -52,36 +59,99 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}; };
useEffect(() => { useEffect(() => {
// Clean up old authentication data on mount
cleanupOldGalleryAuth(); cleanupOldGalleryAuth();
// Check if user has a valid token on mount const slugAtMount = getCurrentGallerySlug();
const currentSlug = getCurrentGallerySlug(); if (slugAtMount) {
if (currentSlug) { setActiveGallerySlug(slugAtMount);
// Try to restore event data from localStorage with slug-specific key } else {
const storedEvent = localStorage.getItem(`gallery_event_${currentSlug}`); clearActiveGallerySlug();
const storedToken = localStorage.getItem(`gallery_token_${currentSlug}`); }
if (storedEvent && storedToken) { const initialise = async () => {
const currentSlug = getCurrentGallerySlug();
if (!currentSlug) {
setIsLoading(false);
return;
}
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try { try {
const eventData = JSON.parse(storedEvent); const parsed = JSON.parse(storedEvent);
// Verify the stored event matches the current gallery slug if (parsed && parsed.id) {
if (eventData && eventData.id) { setEvent(parsed);
setEvent(eventData);
setIsAuthenticated(true);
} else {
// Clear invalid data
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
} }
} catch (error) { } catch (err) {
// Invalid stored data - clear it sessionStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
} }
} }
}
setIsLoading(false); try {
setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
'/auth/session',
{ params: { slug: currentSlug } }
);
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
setIsAuthenticated(true);
if (!storedEvent) {
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) {
setEvent(galleryData.event);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
}
}
return;
}
// If no active session, check for share token in URL
const parts = window.location.pathname.split('/');
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken);
if (response?.event) {
setEvent(response.event);
setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
return;
}
}
}
// No valid session found
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} catch (error) {
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} finally {
setIsLoading(false);
}
};
initialise();
return () => {
clearActiveGallerySlug();
};
}, []); }, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => { const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
@@ -91,10 +161,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event); setEvent(response.event);
setIsAuthenticated(true); setIsAuthenticated(true);
if (response.token) {
// Store event data and token in localStorage with slug-specific key storeGalleryToken(slug, response.token);
localStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); }
localStorage.setItem(`gallery_token_${slug}`, response.token); setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Invalid password'); setError(err.response?.data?.error || 'Invalid password');
throw err; throw err;
@@ -106,12 +179,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const logout = () => { const logout = () => {
const currentSlug = getCurrentGallerySlug(); const currentSlug = getCurrentGallerySlug();
if (currentSlug) { if (currentSlug) {
localStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`); clearGalleryToken(currentSlug);
} }
authService.galleryLogout(); authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false); setIsAuthenticated(false);
setEvent(null); setEvent(null);
clearActiveGallerySlug();
}; };
return ( return (
@@ -128,4 +202,4 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
{children} {children}
</GalleryAuthContext.Provider> </GalleryAuthContext.Provider>
); );
}; };
+31 -19
View File
@@ -1,6 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { ProtectionLevel } from './useImageProtection'; import { ProtectionLevel } from './useImageProtection';
type VendorStyle = CSSStyleDeclaration & {
webkitUserSelect?: string;
webkitTouchCallout?: string;
webkitUserDrag?: string;
};
interface UseCSSProtectionOptions { interface UseCSSProtectionOptions {
enabled: boolean; enabled: boolean;
protectionLevel: ProtectionLevel; protectionLevel: ProtectionLevel;
@@ -63,19 +69,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
// Apply inline styles for enhanced protection // Apply inline styles for enhanced protection
if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') { if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') {
// Disable various browser features // Disable various browser features
container.style.userSelect = 'none'; const containerStyle = container.style as VendorStyle;
container.style.webkitUserSelect = 'none'; containerStyle.userSelect = 'none';
container.style.webkitTouchCallout = 'none'; containerStyle.webkitUserSelect = 'none';
container.style.webkitUserDrag = 'none'; containerStyle.webkitTouchCallout = 'none';
containerStyle.webkitUserDrag = 'none';
// Find all img and canvas elements and protect them // Find all img and canvas elements and protect them
const mediaElements = container.querySelectorAll('img, canvas'); const mediaElements = container.querySelectorAll('img, canvas');
mediaElements.forEach(element => { mediaElements.forEach(element => {
(element as HTMLElement).draggable = false; const el = element as HTMLElement;
(element as HTMLElement).style.userSelect = 'none'; const elStyle = el.style as VendorStyle;
(element as HTMLElement).style.webkitUserSelect = 'none'; el.draggable = false;
(element as HTMLElement).style.webkitUserDrag = 'none'; elStyle.userSelect = 'none';
(element as HTMLElement).style.webkitTouchCallout = 'none'; elStyle.webkitUserSelect = 'none';
elStyle.webkitUserDrag = 'none';
elStyle.webkitTouchCallout = 'none';
if (options.protectionLevel === 'maximum') { if (options.protectionLevel === 'maximum') {
(element as HTMLElement).style.pointerEvents = 'none'; (element as HTMLElement).style.pointerEvents = 'none';
@@ -101,19 +110,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
} }
// Reset inline styles // Reset inline styles
container.style.userSelect = ''; const containerStyle = container.style as VendorStyle;
container.style.webkitUserSelect = ''; containerStyle.userSelect = '';
container.style.webkitTouchCallout = ''; containerStyle.webkitUserSelect = '';
container.style.webkitUserDrag = ''; containerStyle.webkitTouchCallout = '';
containerStyle.webkitUserDrag = '';
// Reset media element styles // Reset media element styles
const mediaElements = container.querySelectorAll('img, canvas'); const mediaElements = container.querySelectorAll('img, canvas');
mediaElements.forEach(element => { mediaElements.forEach(element => {
(element as HTMLElement).style.userSelect = ''; const el = element as HTMLElement;
(element as HTMLElement).style.webkitUserSelect = ''; const elStyle = el.style as VendorStyle;
(element as HTMLElement).style.webkitUserDrag = ''; elStyle.userSelect = '';
(element as HTMLElement).style.webkitTouchCallout = ''; elStyle.webkitUserSelect = '';
(element as HTMLElement).style.pointerEvents = ''; elStyle.webkitUserDrag = '';
elStyle.webkitTouchCallout = '';
elStyle.pointerEvents = '';
}); });
}; };
}, [ }, [
@@ -125,4 +137,4 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
]); ]);
return containerRef; return containerRef;
}; };
+1 -2
View File
@@ -11,7 +11,6 @@ interface UseDevToolsProtectionOptions {
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => { export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null); const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastConsoleCountRef = useRef(0); const lastConsoleCountRef = useRef(0);
const startTimeRef = useRef<number>(Date.now());
const isDetectedRef = useRef(false); const isDetectedRef = useRef(false);
const handleDevToolsDetected = useCallback(() => { const handleDevToolsDetected = useCallback(() => {
@@ -246,4 +245,4 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
isDetectedRef.current = false; isDetectedRef.current = false;
} }
}; };
}; };
+8 -2
View File
@@ -11,9 +11,15 @@ export const useGalleryInfo = (slug: string, token?: string) => {
}); });
}; };
export const useGalleryPhotos = (slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string, enabled: boolean = true) => { export const useGalleryPhotos = (
slug: string,
filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all',
guestId?: string,
enabled: boolean = true
) => {
return useQuery({ return useQuery({
queryKey: ['gallery-photos', slug, filter, guestId], queryKey: ['gallery-photos', slug, filter, guestId],
// Pass guestId so backend can filter per-guest views when needed
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId), queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
enabled, enabled,
retry: 1, retry: 1,
@@ -63,4 +69,4 @@ export const useDownloadAllPhotos = () => {
toast.error('Failed to download photos'); toast.error('Failed to download photos');
}, },
}); });
}; };
+24 -15
View File
@@ -1,5 +1,13 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
type VendorStyle = CSSStyleDeclaration & {
webkitUserSelect?: string;
webkitTouchCallout?: string;
webkitUserDrag?: string;
MozAppearance?: string;
webkitAppearance?: string;
};
export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum'; export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum';
interface UseImageProtectionOptions { interface UseImageProtectionOptions {
@@ -16,9 +24,9 @@ interface UseImageProtectionOptions {
} }
export const useImageProtection = (options: UseImageProtectionOptions) => { export const useImageProtection = (options: UseImageProtectionOptions) => {
const elementRef = useRef<HTMLImageElement>(null); const elementRef = useRef<HTMLImageElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement | null>(null);
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null); const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null); const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -195,25 +203,26 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
} }
// CSS protection // CSS protection
element.style.userSelect = 'none'; const elementStyle = element.style as VendorStyle;
element.style.webkitUserSelect = 'none'; elementStyle.userSelect = 'none';
element.style.webkitTouchCallout = 'none'; elementStyle.webkitUserSelect = 'none';
element.style.pointerEvents = 'auto'; elementStyle.webkitTouchCallout = 'none';
element.style.webkitUserDrag = 'none'; elementStyle.pointerEvents = 'auto';
element.style.webkitTouchCallout = 'none'; elementStyle.webkitUserDrag = 'none';
element.draggable = false; element.draggable = false;
// Enhanced CSS protection // Enhanced CSS protection
if (protectionLevel !== 'basic') { if (protectionLevel !== 'basic') {
element.style.outline = 'none'; elementStyle.outline = 'none';
element.style.webkitAppearance = 'none'; elementStyle.webkitAppearance = 'none';
element.style.MozAppearance = 'none'; elementStyle.MozAppearance = 'none';
// Disable text selection on parent elements // Disable text selection on parent elements
let parent = element.parentElement; let parent = element.parentElement;
while (parent) { while (parent) {
parent.style.userSelect = 'none'; const parentStyle = parent.style as VendorStyle;
parent.style.webkitUserSelect = 'none'; parentStyle.userSelect = 'none';
parentStyle.webkitUserSelect = 'none';
parent = parent.parentElement; parent = parent.parentElement;
} }
} }
@@ -287,4 +296,4 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
canvasRef, canvasRef,
overlayRef overlayRef
}; };
}; };
+90 -3
View File
@@ -478,8 +478,14 @@
"sortByDate": "Nach Datum sortieren", "sortByDate": "Nach Datum sortieren",
"sortByName": "Nach Name sortieren", "sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren", "sortBySize": "Nach Größe sortieren",
"allPhotos": "Alle Fotos", "allPhotos": "Alle Fotos",
"shareGallery": "Galerie teilen", "feedbackFilter": "Feedback-Filter",
"all": "Alle",
"liked": "Gefallen",
"favorited": "Favorisiert",
"rated": "Bewertet",
"commented": "Kommentiert",
"shareGallery": "Galerie teilen",
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter", "needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
"noPhotosFound": "Keine Fotos gefunden", "noPhotosFound": "Keine Fotos gefunden",
"failedToLoad": "Fotos konnten nicht geladen werden", "failedToLoad": "Fotos konnten nicht geladen werden",
@@ -588,6 +594,13 @@
"photos": "Fotos", "photos": "Fotos",
"categories": "Kategorien", "categories": "Kategorien",
"eventInformation": "Veranstaltungsinformationen", "eventInformation": "Veranstaltungsinformationen",
"sourceMode": "Quellenmodus",
"sourceModeManaged": "Verwaltet (Upload nach PicPeak)",
"sourceModeReference": "Externen Ordner referenzieren",
"sourceModeHelp": "Nutzen Sie den verwalteten Modus für direkte Uploads oder verweisen Sie auf einen gemounteten /external-media Ordner.",
"externalFolder": "Externer Ordner",
"externalFolderHint": "Diese Ordner stammen aus dem /external-media Mount innerhalb des Containers oder Hosts.",
"externalFolderRequired": "Bitte wählen Sie vor dem Speichern einen externen Ordner aus.",
"welcomeMessage": "Willkommensnachricht", "welcomeMessage": "Willkommensnachricht",
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt", "noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt", "noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
@@ -770,12 +783,66 @@
"dateFormat": "Datumsformat", "dateFormat": "Datumsformat",
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden" "dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
}, },
"publicSite": {
"tabLabel": "Öffentliche Seite",
"badge": "Öffentliche Landingpage",
"title": "Öffentliche Landingpage",
"subtitle": "Veröffentlichen Sie eine anpassbare Landingpage für Gäste, die Ihre Domain besuchen.",
"loading": "Öffentliche Seite wird geladen...",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"htmlLabel": "Landingpage-HTML",
"htmlPlaceholder": "HTML-Markup für Hero, Abschnitte und Handlungsaufrufe einfügen.",
"htmlHelp": "Rohes HTML wird beim Speichern bereinigt. Unsichere Skripte und Attribute werden automatisch entfernt. Platzhalter wie {{company_name}}, {{company_tagline}} und {{support_email}} werden mit den Werten aus den Branding-Einstellungen ersetzt.",
"cssLabel": "Benutzerdefiniertes CSS",
"cssPlaceholder": "Optionale CSS-Anpassungen für Layout, Farben oder Typografie.",
"cssHelp": "Import-Anweisungen und JavaScript-URLs werden entfernt. Verwenden Sie Standard-CSS-Selektoren.",
"saveCta": "Öffentliche Seite speichern",
"saving": "Speichern...",
"saveSuccess": "Öffentliche Seite gespeichert.",
"saveError": "Öffentliche Seite konnte nicht gespeichert werden.",
"resetCta": "Auf Standard zurücksetzen",
"resetting": "Setze zurück...",
"resetSuccess": "Landingpage wurde auf die Standardvorlage zurückgesetzt.",
"resetError": "Zurücksetzen auf Standard fehlgeschlagen.",
"previewTitle": "Live-Vorschau",
"previewSandboxed": "Sandbox-Vorschau",
"previewDisabled": "Aktivieren Sie die öffentliche Seite, um die Live-Vorschau zu sehen.",
"sanitizationNotice": "Skripte, iframes und unsichere Attribute werden vor der Veröffentlichung entfernt. Erlaubt sind sichere HTML-Elemente, Links und Bilder.",
"htmlRequired": "Legen Sie HTML-Inhalt fest, bevor die öffentliche Seite aktiviert wird."
},
"storage": { "storage": {
"title": "Speicher", "title": "Speicher",
"overview": "Speicherübersicht", "overview": "Speicherübersicht",
"totalUsed": "Gesamt verwendet", "totalUsed": "Gesamt verwendet",
"archiveStorage": "Archivspeicher", "archiveStorage": "Archivspeicher",
"storageLimit": "Speicherlimit", "storageLimit": "Speicherlimit",
"storageLimitHelper": "Definieren Sie ein Limit für die Speichernutzung. Dieses Limit dient als Hinweis und wird hervorgehoben, sobald es erreicht ist.",
"softLimitInputLabel": "Limit (GB)",
"softLimitHelper": "Geben Sie das gewünschte maximale Speicherlimit in Gigabyte ein.",
"recommendedSoftLimit": "Empfohlenes Limit",
"diskCapacity": "Festplattenkapazität",
"diskCapacityReported": "Festplattenkapazität (gemeldet)",
"diskAvailable": "Verfügbar",
"diskAvailableReported": "Verfügbar (gemeldet)",
"diskFree": "Frei",
"diskFreeReported": "Frei (gemeldet)",
"diskMetricsUnavailable": "Speicherinformationen sind in Docker Desktop oder virtuellen Umgebungen nicht verfügbar.",
"applyRecommended": "Empfehlung nutzen",
"applyAvailable": "An verfügbaren Speicher anpassen",
"invalidSoftLimit": "Bitte geben Sie eine gültige Zahl für das Limit ein.",
"saveSoftLimit": "Limit speichern",
"limitNotEnforced": "Dieses Limit dient nur als Hinweis und wird nicht automatisch durchgesetzt.",
"overrideTitle": "Manuelle Kapazitätsüberschreibung",
"diskOverrideEnvNote": "Die Speicherkapazität wird über Umgebungsvariablen gesteuert und kann hier nicht geändert werden.",
"diskOverrideSettingsHelp": "Setzen Sie eigene Werte, wenn Docker unrealistische Zahlen meldet.",
"overrideCapacityLabel": "Gesamtkapazität (GB)",
"overrideCapacityHelper": "Leer lassen, um die automatische Erkennung zu verwenden.",
"overrideAvailableLabel": "Verfügbarer Speicher (GB)",
"overrideAvailableHelper": "Optional. Leer lassen, um aus Gesamtkapazität minus belegtem Speicher zu berechnen.",
"saveOverride": "Kapazität speichern",
"capacityRequiredForAvailable": "Geben Sie zuerst eine Gesamtkapazität an, bevor Sie verfügbaren Speicher setzen.",
"availableExceedsCapacity": "Der verfügbare Speicher darf die Gesamtkapazität nicht überschreiten.",
"storageUsage": "Speichernutzung", "storageUsage": "Speichernutzung",
"storageByEvent": "Speicher nach Veranstaltung", "storageByEvent": "Speicher nach Veranstaltung",
"storageManagement": "Speicherverwaltung", "storageManagement": "Speicherverwaltung",
@@ -988,7 +1055,9 @@
"activeGalleries": "Aktive Galerien", "activeGalleries": "Aktive Galerien",
"storageUsed": "Speicher verwendet", "storageUsed": "Speicher verwendet",
"totalPhotos": "Gesamte Fotos", "totalPhotos": "Gesamte Fotos",
"storagePercent": "{{percent}}% von {{limit}}", "storagePercent": "{{percent}}% des Limits {{limit}}",
"storageSoftLimitConfigured": "Konfiguriertes Limit: {{limit}}",
"storageSoftLimitRecommended": "Empfohlenes Limit: {{limit}}",
"version": "Version", "version": "Version",
"archivedEvents": "Archivierte Veranstaltungen", "archivedEvents": "Archivierte Veranstaltungen",
"systemHealth": "Systemstatus", "systemHealth": "Systemstatus",
@@ -1079,6 +1148,24 @@
"bulk_download": "{{count}} Fotos heruntergeladen von {{eventName}}", "bulk_download": "{{count}} Fotos heruntergeladen von {{eventName}}",
"gallery_password_entry": "Passwort eingegeben für {{eventName}}", "gallery_password_entry": "Passwort eingegeben für {{eventName}}",
"expiration_warning_viewed": "Ablaufwarnung angesehen für {{eventName}}", "expiration_warning_viewed": "Ablaufwarnung angesehen für {{eventName}}",
"feedback_settings_updated": "Feedback-Einstellungen aktualisiert",
"feedback_moderated": "Feedback moderiert",
"feedback_deleted": "Feedback gelöscht",
"photo_like": "Foto mit Gefällt mir markiert in {{eventName}}",
"photo_favorite": "Foto favorisiert in {{eventName}}",
"photo_rating": "Foto bewertet in {{eventName}}",
"photo_comment": "Foto kommentiert in {{eventName}}",
"guest_feedback_like": "Gast hat ein Foto mit Gefällt mir markiert in {{eventName}}",
"guest_feedback_favorite": "Gast hat ein Foto favorisiert in {{eventName}}",
"guest_feedback_rating": "Gast hat ein Foto bewertet in {{eventName}}",
"guest_feedback_comment": "Gast hat ein Foto kommentiert in {{eventName}}",
"word_filter_added": "Wortfilter hinzugefügt",
"external_import_completed": "Externer Medienimport abgeschlossen ({{imported}} importiert, {{skipped}} übersprungen)",
"bulk_archive_completed": "Sammelarchivierung abgeschlossen",
"event_activated": "Veranstaltung aktiviert: {{eventName}}",
"event_deactivated": "Veranstaltung deaktiviert: {{eventName}}",
"photo_deleted": "Foto gelöscht aus {{eventName}}",
"photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}",
"settings_updated": "Einstellungen aktualisiert", "settings_updated": "Einstellungen aktualisiert",
"event_updated": "Veranstaltung aktualisiert: {{eventName}}", "event_updated": "Veranstaltung aktualisiert: {{eventName}}",
"event_deleted": "Veranstaltung gelöscht: {{eventName}}", "event_deleted": "Veranstaltung gelöscht: {{eventName}}",
+82 -1
View File
@@ -278,6 +278,13 @@
"photos": "Photos", "photos": "Photos",
"categories": "Categories", "categories": "Categories",
"eventInformation": "Event Information", "eventInformation": "Event Information",
"sourceMode": "Source Mode",
"sourceModeManaged": "Managed (upload to PicPeak)",
"sourceModeReference": "Reference external folder",
"sourceModeHelp": "Use managed mode for direct uploads or point to a mounted /external-media folder when using local storage.",
"externalFolder": "External Folder",
"externalFolderHint": "These folders are read from the /external-media mount inside your container or host.",
"externalFolderRequired": "Please select an external folder before saving.",
"welcomeMessage": "Welcome Message", "welcomeMessage": "Welcome Message",
"noWelcomeMessage": "No welcome message set", "noWelcomeMessage": "No welcome message set",
"created": "Created", "created": "Created",
@@ -456,12 +463,66 @@
"dateFormat": "Date Format", "dateFormat": "Date Format",
"dateFormatHelp": "How dates are displayed in emails and throughout the application" "dateFormatHelp": "How dates are displayed in emails and throughout the application"
}, },
"publicSite": {
"tabLabel": "Public Site",
"badge": "Public Landing",
"title": "Public Landing Page",
"subtitle": "Publish a customized landing page for guests when they visit your domain.",
"loading": "Loading public site settings...",
"enabled": "Enabled",
"disabled": "Disabled",
"htmlLabel": "Landing Page HTML",
"htmlPlaceholder": "Provide the HTML markup for your landing page hero, sections, and calls to action.",
"htmlHelp": "Raw HTML is sanitized on save. Unsafe tags, inline scripts, and disallowed attributes are removed automatically. Tokens such as {{company_name}}, {{company_tagline}}, and {{support_email}} will be substituted with values from Branding settings.",
"cssLabel": "Custom CSS",
"cssPlaceholder": "Optional CSS overrides to adjust layout, colors, or typography.",
"cssHelp": "Imports and JavaScript URLs are stripped. Use standard CSS selectors to override the default styling.",
"saveCta": "Save public site",
"saving": "Saving...",
"saveSuccess": "Public site settings saved.",
"saveError": "Failed to save public site settings.",
"resetCta": "Reset to default",
"resetting": "Resetting...",
"resetSuccess": "Public site template reset to defaults.",
"resetError": "Failed to reset public site template.",
"previewTitle": "Live Preview",
"previewSandboxed": "Sandboxed preview",
"previewDisabled": "Enable the public site to see the live preview.",
"sanitizationNotice": "Scripts, embedded frames, and unsafe attributes are removed before publishing. Only safe HTML, links, and images are allowed.",
"htmlRequired": "Provide HTML content before enabling the public site."
},
"storage": { "storage": {
"title": "Storage", "title": "Storage",
"overview": "Storage Overview", "overview": "Storage Overview",
"totalUsed": "Total Used", "totalUsed": "Total Used",
"archiveStorage": "Archive Storage", "archiveStorage": "Archive Storage",
"storageLimit": "Storage Limit", "storageLimit": "Storage Limit",
"storageLimitHelper": "Set a limit for photo storage usage. This limit is advisory and will highlight when reached.",
"softLimitInputLabel": "Limit (GB)",
"softLimitHelper": "Enter the preferred maximum storage usage in gigabytes.",
"recommendedSoftLimit": "Suggested Limit",
"diskCapacity": "Disk Capacity",
"diskCapacityReported": "Disk Capacity (reported)",
"diskAvailable": "Available",
"diskAvailableReported": "Available (reported)",
"diskFree": "Free",
"diskFreeReported": "Free (reported)",
"diskMetricsUnavailable": "Disk metrics are not available in Docker Desktop or virtualized environments.",
"applyRecommended": "Use recommended",
"applyAvailable": "Match available",
"invalidSoftLimit": "Enter a valid number for the limit.",
"saveSoftLimit": "Save limit",
"limitNotEnforced": "This limit is advisory only and is not enforced automatically.",
"overrideTitle": "Manual capacity override",
"diskOverrideEnvNote": "Disk capacity is controlled by environment variables and cannot be edited here.",
"diskOverrideSettingsHelp": "Set custom capacity values when Docker reports unrealistic figures.",
"overrideCapacityLabel": "Total capacity (GB)",
"overrideCapacityHelper": "Leave blank to use the automatic reading.",
"overrideAvailableLabel": "Available space (GB)",
"overrideAvailableHelper": "Optional. Leave blank to calculate from total capacity minus used storage.",
"saveOverride": "Save capacity override",
"capacityRequiredForAvailable": "Enter a total capacity before setting available space.",
"availableExceedsCapacity": "Available space cannot exceed the total capacity.",
"storageUsage": "Storage Usage", "storageUsage": "Storage Usage",
"storageByEvent": "Storage by Event", "storageByEvent": "Storage by Event",
"storageManagement": "Storage Management", "storageManagement": "Storage Management",
@@ -732,7 +793,9 @@
"activeGalleries": "Active Galleries", "activeGalleries": "Active Galleries",
"storageUsed": "Storage Used", "storageUsed": "Storage Used",
"totalPhotos": "Total Photos", "totalPhotos": "Total Photos",
"storagePercent": "{{percent}}% of {{limit}}", "storagePercent": "{{percent}}% of limit {{limit}}",
"storageSoftLimitConfigured": "Configured limit: {{limit}}",
"storageSoftLimitRecommended": "Suggested limit: {{limit}}",
"version": "Version", "version": "Version",
"archivedEvents": "Archived Events", "archivedEvents": "Archived Events",
"systemHealth": "System Health", "systemHealth": "System Health",
@@ -821,6 +884,24 @@
"bulk_download": "{{count}} photos downloaded from {{eventName}}", "bulk_download": "{{count}} photos downloaded from {{eventName}}",
"gallery_password_entry": "Password entered for {{eventName}}", "gallery_password_entry": "Password entered for {{eventName}}",
"expiration_warning_viewed": "Expiration warning viewed for {{eventName}}", "expiration_warning_viewed": "Expiration warning viewed for {{eventName}}",
"feedback_settings_updated": "Feedback settings updated",
"feedback_moderated": "Feedback moderated",
"feedback_deleted": "Feedback deleted",
"photo_like": "Photo liked in {{eventName}}",
"photo_favorite": "Photo favorited in {{eventName}}",
"photo_rating": "Photo rated in {{eventName}}",
"photo_comment": "Photo commented in {{eventName}}",
"guest_feedback_like": "Guest liked a photo in {{eventName}}",
"guest_feedback_favorite": "Guest favorited a photo in {{eventName}}",
"guest_feedback_rating": "Guest rated a photo in {{eventName}}",
"guest_feedback_comment": "Guest commented on a photo in {{eventName}}",
"word_filter_added": "Word filter added",
"external_import_completed": "External media import completed ({{imported}} imported, {{skipped}} skipped)",
"bulk_archive_completed": "Bulk archive completed",
"event_activated": "Event activated: {{eventName}}",
"event_deactivated": "Event deactivated: {{eventName}}",
"photo_deleted": "Photo deleted from {{eventName}}",
"photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}",
"settings_updated": "Settings updated", "settings_updated": "Settings updated",
"event_updated": "Event updated: {{eventName}}", "event_updated": "Event updated: {{eventName}}",
"event_deleted": "Event deleted: {{eventName}}", "event_deleted": "Event deleted: {{eventName}}",

Some files were not shown because too many files have changed in this diff Show More