Compare commits

...

117 Commits

Author SHA1 Message Date
Paul Nothaft 8cc5685428 Merge pull request #160 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.9.0-beta.0
2026-02-01 22:59:20 +01:00
github-actions[bot] 7bf1e5c0f9 chore(beta): release 3.9.0-beta.0 2026-02-01 21:58:22 +00:00
Paul Nothaft 7037106bff Merge pull request #159 from the-luap/feat/new-features
feat: gallery layouts, bulk category editing, and hero header improvements
2026-02-01 22:58:08 +01:00
Paul Nothaft eca36c70a2 feat: add bulk category editing for photos (#157)
Add BulkCategoryModal component that allows selecting multiple photos
and moving them to a different category in one operation.
2026-02-01 22:48:08 +01:00
Paul Nothaft 7b8d8bd92b feat: decouple hero header from gallery layouts (#158)
- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
2026-02-01 22:44:28 +01:00
Paul Nothaft 397d33a95a fix: increase upload limit to 1GB and fix category filters (#155, #156)
- Increase nginx client_max_body_size from 100MB to 1GB for video support
- Fix admin photo category filtering to properly handle numeric category IDs
  from the photo_categories table, not just legacy 'individual'/'collage' types
- Add support for 'uncategorized' filter to show photos with no category
2026-02-01 21:07:44 +01:00
Paul Nothaft 08c2e4530e Merge pull request #154 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.8.0-beta.0
2026-01-30 08:35:15 +01:00
github-actions[bot] 9ec0e2e7c0 chore(beta): release 3.8.0-beta.0 2026-01-30 07:34:38 +00:00
Paul Nothaft aacfcd517e Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
2026-01-30 08:34:24 +01:00
Paul Nothaft 27ff51e7a1 fix: use photo dimensions for mosaic aspect ratios (#146)
Thumbnails are generated as 300x300 squares, so CSS Columns alone
couldn't show varied aspect ratios. Now using the photo's width/height
metadata with CSS aspect-ratio property to force correct proportions.
2026-01-30 08:23:58 +01:00
Paul Nothaft 821d3296ea fix: use CSS Columns for gap-free mosaic layout (#146)
Replaced CSS Grid with span rules approach with CSS Columns to eliminate
gaps and white spaces in the mosaic layout. Images now flow vertically
within columns, maintaining their natural aspect ratios without gaps.
2026-01-29 23:16:14 +01:00
Paul Nothaft 46ed1bc276 feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146)
- Add migration to backfill width/height for existing photos without dimensions
- Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio)
- Rewrite mosaic layout to use proper CSS Grid with span rules
- Fix theme not being applied after gallery login
- Improve columns mode distribution using shortest-column algorithm
- Apply gallery theme regardless of authentication status
2026-01-29 23:09:12 +01:00
Paul Nothaft 8711f967a1 fix: use actual photo aspect ratios in masonry columns mode (#146)
Previously, the Pinterest-style columns mode assigned random heights to
photos, causing landscape images to be cropped into portrait slots.
Now the height is calculated based on the photo's actual aspect ratio
and the column width, preserving natural proportions.
2026-01-29 21:40:41 +01:00
Paul Nothaft 5c8aed5793 Merge pull request #151 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.7.0-beta.0
2026-01-28 22:54:59 +01:00
github-actions[bot] c40f34d3de chore(beta): release 3.7.0-beta.0 2026-01-28 21:54:14 +00:00
Paul Nothaft ef2ae00ff2 Merge pull request #150 from the-luap/feat/new-features
feat: Add justified layout modes and aspect-ratio-aware mosaic (#146)
2026-01-28 22:53:58 +01:00
Paul Nothaft 608bbd50e7 feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options
- Implement aspect-ratio-aware mosaic layout that dynamically selects
  patterns based on photo orientations to minimize cropping
- Add 9 mosaic pattern types optimized for different orientation combinations
- Add theme customizer options for masonry mode selection (columns/rows/flickr/justified)
- Add i18n translations for new layout options
2026-01-28 22:31:34 +01:00
Paul Nothaft e3024e6ffd Merge pull request #148 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.6.0-beta.0
2026-01-27 11:47:16 +01:00
github-actions[bot] b4978c0869 chore(beta): release 3.6.0-beta.0 2026-01-27 10:45:56 +00:00
Paul Nothaft cd1d50474f Merge pull request #147 from the-luap/feat/new-features
feat: add justified/rows layout mode to masonry gallery (#146) + security fixes
2026-01-27 11:45:38 +01:00
Paul Nothaft 8097a0cb53 fix: update packages to fix security vulnerabilities
- react-router-dom 6.30.2 → 6.30.3 (XSS via Open Redirects)
- react-router 6.30.2 → 6.30.3
- @remix-run/router 1.23.1 → 1.23.2
- lodash 4.17.21 → 4.17.23 (Prototype Pollution)
2026-01-27 11:40:18 +01:00
Paul Nothaft e081b56a44 feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry:

- Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos)
- Create justifiedLayoutCalculator utility for row-based layouts
- Extract and store image dimensions on upload for layout calculations
- Include width/height in gallery API response
- Add row height and last row behavior controls to theme customizer
- Support responsive container width detection with ResizeObserver

Photos in rows mode maintain their aspect ratios while filling
horizontal rows at a consistent height. The number of photos per
row is automatically calculated based on target row height and
photo dimensions.

Closes #146
2026-01-27 09:58:09 +01:00
Paul Nothaft c2309af3e0 Merge pull request #144 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.5.0-beta.0
2026-01-25 15:25:57 +01:00
github-actions[bot] 32fc939c7a chore(beta): release 3.5.0-beta.0 2026-01-25 14:24:28 +00:00
Paul Nothaft 4c081601e0 Merge pull request #143 from the-luap/feat/new-features
feat: per-event custom logos, customizable event types, and multiple bug fixes
2026-01-25 15:24:14 +01:00
Paul Nothaft 85170b883f feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.

Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
2026-01-23 22:01:02 +01:00
Paul Nothaft c018604e5d Merge pull request #142 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.4.0-beta.0
2026-01-22 14:03:45 +01:00
github-actions[bot] 9c8b5e9fd6 chore(beta): release 3.4.0-beta.0 2026-01-22 13:00:33 +00:00
Paul Nothaft 151e1bf50f Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
2026-01-22 14:00:14 +01:00
Paul Nothaft c5a8ffc08c fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.

- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
2026-01-22 13:54:23 +01:00
Paul Nothaft d4a15dbe74 fix: remove non-functional watermark toggle from Feature Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.

This removes the dead toggle to eliminate user confusion (fixes #140).
2026-01-22 13:54:23 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

Changes include:
- Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields
- Backend routes updated to handle new settings
- Frontend admin page with logo customization controls
- HeroGalleryLayout component with dynamic logo rendering
- i18n translations for EN and DE

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft f8881d5bd6 feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom
event types beyond the default presets (wedding, birthday, corporate, other).

Backend:
- Add event_types table migration with default system types
- Create eventTypeService for CRUD operations with legacy fallback
- Add adminEventTypes routes with full REST API
- Update event validation to use dynamic event types
- Update slug generation to use custom slug_prefix

Frontend:
- Add EventTypesPage with full CRUD admin interface
- Add eventTypes.service.ts API client
- Update CreateEventPage to fetch types dynamically
- Add Event Types navigation in admin sidebar
- Add i18n translations (EN/DE)

Backward compatible: existing galleries continue to work, legacy types
accepted even if database is empty via fallback mechanisms.
2026-01-22 13:54:23 +01:00
Paul Nothaft 6b3ead747b fix: resend gallery email fails for events without password
Added optional chaining when accessing req.body.password in the
resend-email endpoint to handle cases where req.body is undefined.
This prevented the "Cannot read properties of undefined" error.

Fixes #137
2026-01-22 13:54:00 +01:00
Paul Nothaft dadef81158 fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).

Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset

Fixes #136
2026-01-22 13:54:00 +01:00
Paul Nothaft 644ea22b5f Merge pull request #135 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.3.0-beta.0
2026-01-21 17:06:49 +01:00
github-actions[bot] f4da354ae7 chore(beta): release 3.3.0-beta.0 2026-01-21 16:06:24 +00:00
Paul Nothaft a59f41463f Merge pull request #134 from the-luap/feat/optional-event-date-expiration-beta
feat: add original filename preservation and Lightroom export support
2026-01-21 17:06:06 +01:00
Paul Nothaft 9872ad3aef feat: add original filename preservation and Lightroom export support
Addresses GitHub issue #132 - enables filtering client feedback and
exporting filenames for use in Lightroom.

Changes:
- Add original_filename column to photos table via migration
- Store original filename during photo upload
- Fix export service column name mismatches (path, size_bytes, uploaded_at)
- Fix table name (photo_categories instead of categories)
- Fix toFixed() calls to handle string ratings from database

Export formats available:
- TXT with comma separator (for Lightroom Library Filter)
- CSV with full metadata
- JSON for automation
- XMP sidecar files (for Lightroom/Bridge/Capture One)
2026-01-21 16:47:13 +01:00
Paul Nothaft d0880ccb03 Merge pull request #131 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.5-beta.0
2026-01-18 15:38:03 +01:00
github-actions[bot] 237eeea5a6 chore(beta): release 3.2.5-beta.0 2026-01-18 14:35:30 +00:00
Paul Nothaft 41bf6ff884 Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta
fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
2026-01-18 15:35:15 +01:00
Paul Nothaft 991aa98f98 fix: correct invitation activation validation and add missing translations
- Fix password minimum length validation: frontend now correctly requires
  12 characters to match backend validation (was incorrectly checking for 8)
- Fix translation key references in AcceptInvitePage to use correct paths
  (e.g., acceptInvitation.errors.* instead of acceptInvitation.*)
- Add missing translations for both EN and DE:
  - contactAdminMessage
  - passwordsMatch
  - alreadyHaveAccount
  - signIn

Fixes #129
2026-01-18 15:00:38 +01:00
Paul Nothaft 86fa1046d5 fix: correct invitation email link URL path
The invitation email was generating links to /admin/accept-invite/{token}
but the frontend route is configured at /invite/{token}. This caused
invited users to see a blank page when clicking the email link.

Fixes #129
2026-01-18 12:55:56 +01:00
Paul Nothaft 3397807670 docs: emphasize importance of STORAGE_PATH in env example 2026-01-17 15:48:24 +01:00
Paul Nothaft cdda709886 fix: add STORAGE_PATH to production docker-compose
Ensures STORAGE_PATH environment variable is explicitly set in
production deployments to prevent path resolution issues when
serving thumbnails and other storage-related operations.
2026-01-17 15:48:15 +01:00
Paul Nothaft 023bb97e66 Merge pull request #128 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.4-beta.0
2026-01-17 15:08:48 +01:00
github-actions[bot] cf38305f28 chore(beta): release 3.2.4-beta.0 2026-01-17 14:08:09 +00:00
Paul Nothaft 0e3674b2b0 Merge pull request #127 from the-luap/feat/optional-event-date-expiration-beta
fix: correct storage path resolution in multiple files (#96)
2026-01-17 15:07:56 +01:00
Paul Nothaft 3ccb8154eb fix: correct storage path resolution in multiple files (#96)
Fixed inconsistent storage path fallbacks that caused 500 errors when
serving thumbnails. The paths were using '../../storage' (2 levels up)
instead of '../../../storage' (3 levels up) when STORAGE_PATH env var
is not set.

Affected files:
- backend/src/routes/gallery.js
- backend/src/services/photoService.js
- backend/src/services/eventService.js
2026-01-17 14:01:29 +01:00
Paul Nothaft b5ac18121d Merge pull request #126 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.3-beta.0
2026-01-16 15:23:29 +01:00
github-actions[bot] b613f8fbc7 chore(beta): release 3.2.3-beta.0 2026-01-16 14:19:26 +00:00
Paul Nothaft cacaffa5c3 Merge pull request #125 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button not visible in gallery (#113)
2026-01-16 15:19:09 +01:00
Paul Nothaft 691e3aba09 fix: add allow_user_uploads to gallery API responses
The gallery /photos and /info endpoints were not returning the
allow_user_uploads field, causing the upload button to never show
in the frontend since the value was always undefined/false.
2026-01-16 15:15:09 +01:00
Paul Nothaft 70a0caa11f Merge pull request #124 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.2-beta.0
2026-01-16 14:53:44 +01:00
github-actions[bot] e808e529cd chore(beta): release 3.2.2-beta.0 2026-01-16 13:53:31 +00:00
Paul Nothaft 05a5307e22 Merge pull request #123 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:53:17 +01:00
Paul Nothaft 2a2c23d116 fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:49:37 +01:00
Paul Nothaft a092d98523 Merge pull request #122 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.1-beta.0
2026-01-16 14:38:39 +01:00
github-actions[bot] b5f06af126 chore(beta): release 3.2.1-beta.0 2026-01-16 13:35:53 +00:00
Paul Nothaft 6cb43428d1 Merge pull request #121 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:35:36 +01:00
Paul Nothaft df7dbffbff fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:29:49 +01:00
Paul Nothaft 94421a6b12 Merge pull request #120 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.0-beta.0
2026-01-16 09:43:51 +01:00
github-actions[bot] 7805e89bfe chore(beta): release 3.2.0-beta.0 2026-01-16 08:43:36 +00:00
Paul Nothaft 3079eaa2e5 Merge pull request #119 from the-luap/feat/optional-event-date-expiration-beta
feat: add optional event date and expiration settings
2026-01-16 09:43:23 +01:00
Paul Nothaft 2151147f2d feat: add optional event date and expiration settings
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
2026-01-16 09:39:32 +01:00
Paul Nothaft 3e69579f5a docs: add API_URL environment variable to .env.example files
Document the API_URL environment variable that is used for constructing
URLs for assets (logos, images) in email notifications. Without this
setting, the system defaults to http://localhost:3001 which causes
broken images in production emails.

Added to both root and backend .env.example files with clear
documentation about its purpose and importance.
2026-01-16 09:39:32 +01:00
Paul Nothaft 808ed1d2f1 fix: checkbox and toggle settings not persisting after page refresh
PostgreSQL's json column type returns parsed values directly (boolean
false instead of string "false"). The backend code used a truthy check
which failed for boolean false values, causing null to be returned
instead of the actual false value.

Changed condition from `if (setting.setting_value)` to explicit null
check `if (setting.setting_value !== null && setting.setting_value !== undefined)`
and added handling for already-parsed json column values.

Fixes #117
2026-01-16 09:39:32 +01:00
Paul Nothaft b40e085d28 Merge pull request #116 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m18s
Build and Push Docker Images / build-frontend (push) Failing after 3m15s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.1.0-beta.0
2026-01-15 15:04:17 -05:00
github-actions[bot] d603567e21 chore(beta): release 3.1.0-beta.0 2026-01-15 20:03:57 +00:00
Paul Nothaft c6fdd38e84 Merge pull request #115 from the-luap/fix/codeql-v4-upgrade
feat: pre-generated watermarks and mobile upload button improvements
2026-01-15 15:03:25 -05:00
Paul Nothaft ae181cf92f fix: show upload button in mobile topbar instead of sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.

- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar

Fixes #113
2026-01-15 21:00:17 +01:00
Paul Nothaft 1be974afbb feat: pre-generate watermarks for instant lightbox loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.

This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).

- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
Paul Nothaft 1c8f686c19 Merge pull request #102 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / build-frontend (push) Failing after 3m8s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.1-beta.0
2026-01-15 06:36:13 -05:00
github-actions[bot] a0f38053d3 chore(beta): release 3.0.1-beta.0 2026-01-15 11:35:58 +00:00
Paul Nothaft cb012186d9 Merge pull request #101 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:35:46 -05:00
Paul Nothaft fe7d45dd12 fix: use Release Please extra-files instead of sync-versions job
Remove sync-versions job that fails on protected branches.
Instead, use Release Please's extra-files feature to update
package.json versions as part of the release PR.
2026-01-15 12:32:07 +01:00
Paul Nothaft c05ae5b0b9 chore: upgrade CodeQL Action from v3 to v4
Address deprecation warning - CodeQL Action v3 will be deprecated in December 2026.
2026-01-15 12:30:25 +01:00
Paul Nothaft dab012c3d1 Merge pull request #100 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m31s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.0-beta.0
2026-01-15 06:28:27 -05:00
github-actions[bot] 32492c5a91 chore(beta): release 3.0.0-beta.0 2026-01-15 11:24:18 +00:00
github-actions[bot] 2add85eccf chore: sync package.json versions to 2.3.0 2026-01-15 11:19:05 +00:00
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m13s
Build and Push Docker Images / build-frontend (push) Failing after 3m30s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

Release Channels:
- Stable channel: production-ready releases (stable, latest, v2.3.0)
- Beta channel: early access features (beta, v2.3.0-beta.1)
- Configurable via PICPEAK_CHANNEL environment variable

Update Notifications:
- Admin dashboard shows available updates for configured channel
- Checks GitHub Releases API with 1-hour cache
- Can be disabled with UPDATE_CHECK_ENABLED=false

CI/CD Changes:
- New release-please-beta.yml workflow for beta prereleases
- Docker build workflow produces stable/beta tags based on branch
- Beta versions use v2.3.0-beta.1 format

New Files:
- .github/workflows/release-please-beta.yml
- release-please-config-beta.json
- .release-please-manifest-beta.json
- backend/src/services/updateCheckService.js
- frontend/src/components/admin/UpdateNotification.tsx

Modified Files:
- docker-compose.production.yml (channel selection)
- .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED)
- backend/src/routes/adminSystem.js (/updates endpoint)
- frontend components (VersionInfo, AdminDashboard)
- i18n locales (en.json, de.json)
- README.md and DEPLOYMENT_GUIDE.md (documentation)
2026-01-15 12:11:06 +01:00
Paul Nothaft e3c3c4c951 fix: gallery thumbnails not loading (404 errors) #96
The gallery thumbnail endpoint was returning 404 when thumbnail_path
was null or the file didn't exist, unlike the admin endpoint which
generates thumbnails on demand using ensureThumbnail().

- Import ensureThumbnail from imageProcessor
- Use ensureThumbnail() in gallery thumbnail route to generate
  thumbnails on demand if they don't exist
- This matches the admin endpoint behavior

Fixes #96
2026-01-15 11:22:13 +01:00
Paul Nothaft 0e3b50d1b6 fix: watermark upload JSON parsing and image quality preservation
- Fix JSON parsing error when uploading watermark logo by handling both
  JSON-stringified and raw string paths
- Ensure publicPath is JSON.stringify'd consistently when saving
- Preserve original image format (PNG/WebP/JPEG) when applying watermarks
- Use maximum quality (100) to prevent unnecessary recompression
2026-01-12 13:24:21 +01:00
Paul Nothaft bd8b885f7f fix: display new password after admin password reset
- show-admin-credentials.js --reset now displays the generated password
  instead of just saying "[NEWLY RESET - stored in database]"
- Also sets must_change_password flag to force password change on login
- Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the
  new password is displayed in console output after reset
2026-01-12 13:23:24 +01:00
Paul Nothaft 3cdc0ea715 fix: prevent unnecessary image recompression and fix SQLite migration #95
- Skip image processing for basic/standard protection levels when no
  fingerprinting or watermarking is enabled
- Preserve original image format (PNG/WebP/JPEG) instead of always
  converting to JPEG
- Fix SQLite migration failure for fresh installations by adding
  multilingual columns to email_templates table before inserting
  admin email templates

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
Paul Nothaft 3f7631cd95 Merge pull request #93 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m9s
Build and Push Docker Images / build-frontend (push) Failing after 3m9s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.4
2026-01-08 23:23:57 +01:00
github-actions[bot] 53b8764ed7 chore(main): release 2.2.4 2026-01-08 22:22:51 +00:00
Paul Nothaft 082d8ab205 fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
2026-01-08 23:22:33 +01:00
Paul Nothaft 749100c92a fix(backup): add lastBackup alias and totalBackups for frontend compatibility
The frontend expected `status.lastBackup` but the backend was returning
`status.lastRun`. This caused the backup dashboard to show "No backup available"
even when backups existed in the history.

Added:
- `lastBackup` as alias for `lastRun`
- `totalBackups` count of completed backups
2026-01-08 23:11:48 +01:00
Paul Nothaft 3798662722 Merge pull request #91 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m22s
Build and Push Docker Images / build-frontend (push) Failing after 3m0s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.3
2026-01-08 18:26:58 +01:00
github-actions[bot] fa1397cb8c chore(main): release 2.2.3 2026-01-08 17:26:11 +00:00
Paul Nothaft cc1ddfd42c fix(nginx): Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3)
Fixes 502 Bad Gateway on root path in Docker Swarm by adding DNS resolver
  configuration (127.0.0.11) and dynamic DNS resolution for all proxy_pass 
  directives. This ensures nginx resolves backend service IPs on each request 
  rather than caching them at startup.
2026-01-08 18:25:54 +01:00
Paul Nothaft 049837f9d6 fix(nginx): add Docker DNS resolver for Swarm/dynamic service discovery
- Add resolver 127.0.0.11 directive for Docker's internal DNS
- Use variable-based proxy_pass to force per-request DNS resolution
- Fix 502 Bad Gateway error on root path in Docker Swarm deployments

The issue was that nginx caches DNS lookups at startup, but in Docker
Swarm where service IPs can change dynamically, this caused stale DNS
entries leading to 502 errors for proxied requests.

Bumps version to 2.2.3
2026-01-08 16:25:05 +01:00
Paul Nothaft 29dc2a3cf1 Merge pull request #89 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.2
2026-01-08 15:53:44 +01:00
github-actions[bot] d2cba449b0 chore(main): release 2.2.2 2026-01-08 14:51:24 +00:00
Paul Nothaft e0bd19a74d fix: Align nginx backend port for production Docker deployments (v2.2.2) (#88)
Fix 502 Bad Gateway on root path in production Docker/Traefik deployments.

  - nginx.conf: backend:3001 → backend:3000 (matches production container port)
  - docker-compose.yml: align dev environment to use port 3000
  - Bump version to 2.2.2
2026-01-08 15:51:10 +01:00
Paul Nothaft 0ab8cbde7f chore: bump version to 2.2.2
Includes fix for nginx backend port alignment (3001 → 3000) that caused
502 errors on root path in production Docker deployments.
2026-01-08 15:46:41 +01:00
Paul Nothaft 3a8d53f492 fix: align backend port to 3000 across all configurations
The production docker-compose used port 3000 internally but nginx.conf
was hardcoded to port 3001, causing 502 errors on the root path (/).

Changes:
- Update nginx.conf to use backend:3000
- Update docker-compose.yml to use PORT=3000 for consistency
- Update port mapping and healthcheck to use port 3000
2026-01-08 15:28:35 +01:00
Paul Nothaft 804a964ba0 Merge pull request #87 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 2m58s
Build and Push Docker Images / build-frontend (push) Failing after 3m4s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.1
2026-01-08 14:01:58 +01:00
github-actions[bot] d2cd1aa933 chore(main): release 2.2.1 2026-01-08 13:01:36 +00:00
Paul Nothaft d7ecf83d32 fix: Resolve branding display issues and invitation parsing errors (v2.2.1) (#86)
Fixes #84, Fixes #85
  - Fix uploads proxy routing in nginx and vite dev server
  - Fix logo/favicon state handling in BrandingPage
  - Fix invitation API response field transformation (snake_case → camelCase)  
  - Add hide_powered_by to public settings API
  - Mark Multiple Administrators as implemented in roadmap
  - Bump version to 2.2.1
2026-01-08 14:01:21 +01:00
Paul Nothaft ebb2ce6065 Merge branch 'main' into feature/multiple-administrators 2026-01-08 13:58:41 +01:00
Paul Nothaft 1931d73b60 fix: resolve branding display issues and invitation parsing errors
Fixes #84 - Logo and favicon not displaying on branding page and galleries
Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors

Changes:
- Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching
- Fix vite.config.ts: Add /uploads proxy for development environment
- Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme
- Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response
- Fix publicSettings.js: Add branding_hide_powered_by to public settings API response
- Update README.md: Mark Multiple Administrators feature as implemented
- Bump version to 2.2.1
2026-01-08 13:56:02 +01:00
Paul Nothaft 0d5ce48dcc fix: handle legacy non-JSON logo paths when replacing logo
When uploading a new logo, the code tries to delete the old logo file.
This failed when the old path was stored as a raw path (legacy format)
instead of JSON-serialized. Added check to handle both formats.
2026-01-08 11:44:29 +01:00
Paul Nothaft 4872ef71f8 ci: only build ARM64 images for tagged releases
QEMU emulation of ARM64 on x86 GitHub runners is too slow and
unreliable for npm operations, causing builds to hang or crash
with "Illegal instruction" errors.

Changed platform detection logic to:
- Tagged releases (v*.*.*): Build both amd64 and arm64
- All other builds (branches, PRs): Build amd64 only

This ensures fast CI feedback during development while still
providing multi-arch images for production releases.
2026-01-08 11:33:20 +01:00
Paul Nothaft b83f4272b5 fix: JSON serialize favicon and logo URLs for PostgreSQL storage
Fixes #84

The favicon and logo upload endpoints were storing URL paths directly
without JSON.stringify(), causing PostgreSQL JSON validation errors
("Token '/' is invalid") since paths like "/uploads/favicons/..."
are not valid JSON.

Applied JSON.stringify() to:
- branding_logo_url setting (lines 358, 364)
- branding_favicon_url setting (lines 894, 900)
2026-01-08 11:29:38 +01:00
github-actions[bot] 5df64992c4 chore: sync package.json versions to 2.2.0 2026-01-08 09:28:03 +00:00
127 changed files with 8521 additions and 1508 deletions
+15
View File
@@ -39,6 +39,11 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
@@ -50,6 +55,16 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
# 'beta' uses the :beta tag for pre-release versions
PICPEAK_CHANNEL=stable
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
+49 -21
View File
@@ -8,10 +8,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, develop ]
tags: [ 'v*.*.*' ] # Triggered by Release Please tags
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main ]
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -42,17 +42,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
else
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
@@ -88,10 +100,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
@@ -123,7 +137,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
@@ -139,17 +153,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
else
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
@@ -185,10 +211,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
@@ -220,7 +248,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
+37
View File
@@ -0,0 +1,37 @@
name: Release Please (Beta)
on:
push:
branches: [beta]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
-35
View File
@@ -34,38 +34,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Sync version to package.json files after release
sync-versions:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Update package.json versions
run: |
VERSION="${{ needs.release-please.outputs.version }}"
echo "Updating package.json files to version $VERSION"
# Update backend package.json
cd backend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
# Update frontend package.json
cd frontend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
- name: Commit version updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
git push
+8
View File
@@ -94,3 +94,11 @@ backup/
# Local SQLite files in backend
backend/*.sqlite*
# Test files and artifacts
test-logo*.jpg
test-logo*.png
test-results/
# Development docker compose
docker-compose.dev.yml
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.9.0-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.2.0"
".": "2.3.0"
}
+397
View File
@@ -5,6 +5,403 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.8.0-beta.0...v3.9.0-beta.0) (2026-02-01)
### Features
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
### Bug Fixes
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
## [3.8.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.7.0-beta.0...v3.8.0-beta.0) (2026-01-30)
### Features
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
### Bug Fixes
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
## [3.7.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.6.0-beta.0...v3.7.0-beta.0) (2026-01-28)
### Features
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
## [3.6.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.5.0-beta.0...v3.6.0-beta.0) (2026-01-27)
### Features
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
### Bug Fixes
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
## [3.5.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.4.0-beta.0...v3.5.0-beta.0) (2026-01-25)
### Features
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
## [3.4.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.3.0-beta.0...v3.4.0-beta.0) (2026-01-22)
### Features
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
### Bug Fixes
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
## [3.3.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.5-beta.0...v3.3.0-beta.0) (2026-01-21)
### Features
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
## [3.2.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.4-beta.0...v3.2.5-beta.0) (2026-01-18)
### Bug Fixes
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
### Documentation
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
## [3.2.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.3-beta.0...v3.2.4-beta.0) (2026-01-17)
### Bug Fixes
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
## [3.2.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.2-beta.0...v3.2.3-beta.0) (2026-01-16)
### Bug Fixes
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
### Bug Fixes
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
### Bug Fixes
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
### Features
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
### Bug Fixes
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
### Features
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
### Bug Fixes
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
### Bug Fixes
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
### ⚠ BREAKING CHANGES
* Deployment now requires external reverse proxy for SSL/HTTPS
### Features
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
* **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 ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
* **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 ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
* **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](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
* **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](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
* **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 ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
### Bug Fixes
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
* **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. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
* handle auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* remove description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
* resolve CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
### Documentation
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
### Code Refactoring
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
## [2.3.0](https://github.com/the-luap/picpeak/compare/v2.2.4...v2.3.0) (2026-01-15)
### Features
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
### Bug Fixes
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
## [2.2.4](https://github.com/the-luap/picpeak/compare/v2.2.3...v2.2.4) (2026-01-08)
### Bug Fixes
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
## [2.2.3](https://github.com/the-luap/picpeak/compare/v2.2.2...v2.2.3) (2026-01-08)
### Bug Fixes
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
## [2.2.2](https://github.com/the-luap/picpeak/compare/v2.2.1...v2.2.2) (2026-01-08)
### Bug Fixes
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
## [2.2.1](https://github.com/the-luap/picpeak/compare/v2.2.0...v2.2.1) (2026-01-08)
### Bug Fixes
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
## [2.2.0](https://github.com/the-luap/picpeak/compare/v2.1.1...v2.2.0) (2026-01-08)
+116 -148
View File
@@ -2,9 +2,24 @@
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
## 📋 Table of Contents
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
@@ -12,27 +27,11 @@ chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This automated script handles everything including:
- Choice between Docker or Native installation
- OS detection and dependency installation
- Database setup and service configuration
- SSL/HTTPS setup (optional)
Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users new to server management
- Quick testing and evaluation
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
@@ -43,35 +42,40 @@ cd picpeak
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 2: Building from Source
### Option 3: Build from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
## 📋 Table of Contents
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [First Login](#first-login)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Maintenance](#maintenance)
- [Troubleshooting](#troubleshooting)
- [External Media Library](#external-media-library)
docker compose build
docker compose up -d
```
## Prerequisites
@@ -80,106 +84,6 @@ If you need to customize the application or the pre-built images aren't availabl
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🚀 Quick Start
### Method 1: Using Pre-built Images (Fastest)
1. **Clone the repository for configs**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Deploy using pre-built images**
```bash
docker compose -f docker-compose.production.yml up -d
```
5. **Check logs**
```bash
docker compose -f docker-compose.production.yml logs -f
```
## External Media Library
PicPeak can reference an existing, readonly media library mounted into the backend container. This avoids copying originals into PicPeak storage.
- Map your host library path to the container as readonly in `docker-compose.production.yml`:
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
- In `.env`, set:
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
- `EXTERNAL_MEDIA_ROOT=/external-media`
Usage:
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
Backups and Archives:
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
- Archiving reference events creates a manifestonly ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
Local (npm) setup (no Docker):
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
2. In `backend/.env` (or your shell), set:
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
3. Start services from source:
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
4. In Admin → Events:
- Create an event, set “Source Mode” to “Reference (external folder)”.
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
- Click “Import from selected folder” to index files and generate thumbnails on demand.
Notes:
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
### Method 2: Building from Source
1. **Clone the repository**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
5. **Check logs**
```bash
docker compose logs -f
```
## 🔧 Configuration
### Essential Environment Variables
@@ -358,14 +262,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- The admin password is only displayed once during initial setup
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
@@ -446,6 +352,61 @@ ADMIN_EMAIL=your-email@yourdomain.com
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
@@ -656,20 +617,27 @@ docker compose up -d
docker compose ps
```
#### Specific Version Updates
#### Specific Version or Channel Updates
To use a specific version of the images:
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit docker-compose.production.yml to specify version tags
# Change: ghcr.io/the-luap/picpeak/backend:latest
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
+46 -2
View File
@@ -79,7 +79,51 @@ Note on Docker file permissions (PUID/PGID)
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with Permission denied.
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker-compose -f docker-compose.production.yml pull
docker-compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```bash
UPDATE_CHECK_ENABLED=false
```
## 📖 Documentation
@@ -248,7 +292,7 @@ These features are currently in beta testing and may have limited functionality
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **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 | ✅ Implemented |
| **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 | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
+2
View File
@@ -451,6 +451,8 @@ cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
### Getting Help
1. **Check logs:**
+6
View File
@@ -14,6 +14,11 @@ ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
@@ -39,6 +44,7 @@ SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=noreply@example.com
# Storage Paths
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
# Docker deployment:
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
+2 -1
View File
@@ -1,6 +1,6 @@
# Database Migrations
This directory contains database migrations for the Wedding Photo Sharing platform.
This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
@@ -3,6 +3,81 @@
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// First, ensure the email_templates table has multilingual columns
// This is needed for fresh installations where legacy migrations don't run
const columnInfo = await knex('email_templates').columnInfo();
if (!columnInfo.subject_en) {
// Need to add multilingual columns
console.log('Adding multilingual columns to email_templates table...');
// Check if we're using SQLite or PostgreSQL
const client = knex.client.config.client;
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
if (isSqlite) {
// SQLite doesn't support column rename directly in all versions
// We need to recreate the table with new structure
// Get existing data
const existingData = await knex('email_templates').select('*');
// Drop the old table
await knex.schema.dropTable('email_templates');
// Create new table with multilingual columns
await knex.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable();
table.string('subject_en');
table.string('subject_de');
table.text('body_html_en');
table.text('body_html_de');
table.text('body_text_en');
table.text('body_text_de');
table.json('variables');
table.datetime('updated_at').defaultTo(knex.fn.now());
});
// Re-insert existing data with column mapping
for (const row of existingData) {
await knex('email_templates').insert({
template_key: row.template_key,
subject_en: row.subject,
subject_de: row.subject, // Copy to German as default
body_html_en: row.body_html,
body_html_de: row.body_html,
body_text_en: row.body_text,
body_text_de: row.body_text,
variables: row.variables,
updated_at: row.updated_at
});
}
console.log('Migrated email_templates table to multilingual structure');
} else {
// PostgreSQL supports ALTER TABLE for column operations
await knex.schema.alterTable('email_templates', (table) => {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
await knex.schema.alterTable('email_templates', (table) => {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy English values to German as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
@@ -0,0 +1,107 @@
/**
* Migration: Add event_types table
*
* Creates a customizable event types system to replace hardcoded event types.
* This allows users to:
* - Rename existing event types (wedding, birthday, corporate, other)
* - Create custom event types with custom slug prefixes
* - Associate default theme presets with event types
*
* Backward compatible: Existing events keep their event_type strings.
* New events can use either legacy strings or custom event type slug_prefix.
*/
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Creating event_types table...');
// Create event_types table
const hasEventTypesTable = await knex.schema.hasTable('event_types');
if (!hasEventTypesTable) {
await knex.schema.createTable('event_types', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
table.string('emoji', 10); // Icon emoji: "👨‍👩‍👧"
table.string('theme_preset', 50); // Default theme: "elegantWedding"
table.text('theme_config'); // Custom theme JSON overrides (optional)
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
table.boolean('is_system').defaultTo(false); // Protect default types
table.boolean('is_active').defaultTo(true); // Allow hiding types
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes for performance
table.index('slug_prefix');
table.index('display_order');
table.index('is_active');
});
console.log('event_types table created');
} else {
console.log('event_types table already exists, skipping creation');
}
// Seed default event types (matching current hardcoded values)
const existingTypes = await knex('event_types').select('slug_prefix');
const existingSlugs = existingTypes.map(t => t.slug_prefix);
const defaultTypes = [
{
name: 'Wedding',
slug_prefix: 'wedding',
emoji: '💒',
theme_preset: 'elegantWedding',
display_order: 1,
is_system: true,
is_active: true
},
{
name: 'Birthday',
slug_prefix: 'birthday',
emoji: '🎂',
theme_preset: 'birthdayFun',
display_order: 2,
is_system: true,
is_active: true
},
{
name: 'Corporate',
slug_prefix: 'corporate',
emoji: '🏢',
theme_preset: 'corporateTimeline',
display_order: 3,
is_system: true,
is_active: true
},
{
name: 'Other',
slug_prefix: 'other',
emoji: '📸',
theme_preset: 'default',
display_order: 4,
is_system: true,
is_active: true
}
];
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
if (typesToInsert.length > 0) {
await knex('event_types').insert(typesToInsert);
console.log(`Inserted ${typesToInsert.length} default event types`);
} else {
console.log('Default event types already exist, skipping seed');
}
console.log('Migration 061_add_event_types_table completed successfully');
};
exports.down = async function(knex) {
console.log('Rolling back event_types table...');
// Drop the table (data will be lost)
await knex.schema.dropTableIfExists('event_types');
console.log('event_types table dropped');
};
@@ -0,0 +1,53 @@
/**
* Migration: Add optional event date and expiration settings
* These settings control whether event_date and expiration are required
* when creating new events, supporting non-event use cases like portraits.
*/
exports.up = async function(knex) {
// Add new settings for optional date and expiration
const settings = [
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
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()
});
}
}
// Make event_date and expires_at columns nullable
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
// SQLite requires table recreation (handled differently)
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// PostgreSQL: directly alter columns
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite: columns are already effectively nullable in most cases
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
// For safety, we'll skip the schema change for SQLite as it's complex
// The application logic will handle null values appropriately
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
}
};
exports.down = async function(knex) {
// Remove the settings
await knex('app_settings')
.whereIn('setting_key', [
'event_require_event_date',
'event_require_expiration'
])
.del();
// Note: We don't restore NOT NULL constraints as that could fail
// if there are existing NULL values in the database
};
@@ -0,0 +1,28 @@
/**
* Migration 061: Add pre-generated watermark path to photos table
* - photos.watermark_path: path to pre-generated watermarked image
* - photos.watermark_generated_at: timestamp of watermark generation
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 061_add_watermark_path');
// photos.watermark_path (nullable - path to pre-generated watermarked image)
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
table.string('watermark_path', 512);
});
// photos.watermark_generated_at (nullable - when watermark was last generated)
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
table.timestamp('watermark_generated_at');
});
console.log('Migration 061_add_watermark_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 061_add_watermark_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,70 @@
/**
* Migration: Add hero logo customization settings to events table
*
* Allows per-event customization of the hero gallery logo:
* - hero_logo_visible: Show/hide the logo overlay
* - hero_logo_size: Logo size (small, medium, large, xlarge)
* - hero_logo_position: Logo position (top, center, bottom)
*
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
*/
exports.up = async function (knex) {
console.log('Adding hero logo settings to events table...');
// Add hero_logo_visible column
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (!hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
});
console.log('Added hero_logo_visible column');
}
// Add hero_logo_size column
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (!hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
});
console.log('Added hero_logo_size column');
}
// Add hero_logo_position column
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (!hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
});
console.log('Added hero_logo_position column');
}
console.log('Migration 062_add_hero_logo_settings completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back hero logo settings...');
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_visible');
});
}
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_size');
});
}
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_position');
});
}
console.log('Hero logo settings columns dropped');
};
@@ -0,0 +1,23 @@
/**
* Migration 062: Add original_filename to photos table
* - photos.original_filename: preserves the original filename from upload
* This enables Lightroom integration by exporting filtered filenames
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 062_add_original_filename');
// photos.original_filename (nullable - original filename before renaming)
await addColumnIfNotExists(knex, 'photos', 'original_filename', (table) => {
table.string('original_filename', 512);
});
console.log('Migration 062_add_original_filename completed');
};
exports.down = async function(knex) {
console.log('Rollback: 062_add_original_filename');
// Keep column (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,53 @@
/**
* Migration: Add custom logo support to events table
*
* Allows per-event custom logo that overrides the global branding logo:
* - hero_logo_url: Public path to the uploaded custom logo
* - hero_logo_path: Full filesystem path to the custom logo
*
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
*
* Addresses GitHub Issue #138: Per-event custom logo option
*/
exports.up = async function (knex) {
console.log('Adding custom logo columns to events table...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (!hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_url', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_url column');
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (!hasPathColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_path', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_path column');
}
console.log('Migration 063_add_event_custom_logo completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back custom logo columns...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_url');
});
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (hasPathColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_path');
});
}
console.log('Custom logo columns dropped');
};
@@ -0,0 +1,105 @@
/**
* Migration: Backfill photo dimensions
*
* This migration extracts width/height from existing photos that don't have
* these dimensions stored. This is needed for aspect-ratio-aware layouts
* (masonry, mosaic, justified) to work properly.
*/
const path = require('path');
const fs = require('fs');
exports.up = async function(knex) {
// Check if the width/height columns exist
const hasWidth = await knex.schema.hasColumn('photos', 'width');
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (!hasWidth || !hasHeight) {
console.log('[Migration 064] Width/height columns not found, skipping backfill');
return;
}
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Find photos without dimensions
const photos = await knex('photos')
.whereNull('width')
.orWhereNull('height')
.select('id', 'path', 'filename', 'media_type');
console.log(`[Migration 064] Found ${photos.length} photos without dimensions`);
if (photos.length === 0) {
return;
}
// Import sharp dynamically (only needed during migration)
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.error('[Migration 064] Sharp not available, skipping backfill:', err.message);
return;
}
let updated = 0;
let failed = 0;
for (const photo of photos) {
try {
// Skip videos - they need ffprobe for metadata
if (photo.media_type === 'video') {
continue;
}
// Construct the full file path
let fullPath;
if (photo.path) {
// Path is relative to events/active directory
fullPath = path.join(storagePath, 'events/active', photo.path);
} else {
console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`);
continue;
}
// Check if file exists
if (!fs.existsSync(fullPath)) {
console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`);
failed++;
continue;
}
// Extract dimensions using sharp
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await knex('photos')
.where('id', photo.id)
.update({
width: metadata.width,
height: metadata.height
});
updated++;
if (updated % 50 === 0) {
console.log(`[Migration 064] Updated ${updated} photos...`);
}
} else {
console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`);
failed++;
}
} catch (err) {
console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message);
failed++;
}
}
console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`);
};
exports.down = async function(knex) {
// This migration only adds data, no rollback needed
// We don't want to null out dimensions on rollback as they're still valid
console.log('[Migration 064] Rollback: No action needed (data-only migration)');
};
@@ -0,0 +1,131 @@
/**
* Migration: Add header_style and hero_divider_style columns
*
* This migration decouples the hero header style from gallery layout,
* allowing any combination of header style with any layout type.
*/
exports.up = async function(knex) {
console.log('[Migration 065] Adding header_style and hero_divider_style columns');
// Check if columns already exist
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (!hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('header_style', 20).defaultTo('standard');
});
console.log('[Migration 065] Added header_style column');
}
if (!hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('hero_divider_style', 20).defaultTo('wave');
});
console.log('[Migration 065] Added hero_divider_style column');
}
// Migrate existing events with hero layout in color_theme
console.log('[Migration 065] Migrating existing hero layouts...');
const events = await knex('events')
.whereNotNull('color_theme')
.select('id', 'color_theme');
let migratedCount = 0;
for (const event of events) {
try {
// Skip if color_theme is not JSON
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Check if this event uses hero layout
if (theme.galleryLayout === 'hero') {
// Migrate: set headerStyle to 'hero' and galleryLayout to 'grid'
const updatedTheme = {
...theme,
headerStyle: 'hero',
galleryLayout: 'grid',
heroDividerStyle: theme.heroDividerStyle || 'wave'
};
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(updatedTheme),
header_style: 'hero',
hero_divider_style: theme.heroDividerStyle || 'wave'
});
migratedCount++;
}
} catch (err) {
// Invalid JSON in color_theme, skip
console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`);
}
}
console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`);
console.log('[Migration 065] Completed');
};
exports.down = async function(knex) {
console.log('[Migration 065] Removing header_style and hero_divider_style columns');
// First, migrate any hero header styles back to hero layout
const events = await knex('events')
.where('header_style', 'hero')
.whereNotNull('color_theme')
.select('id', 'color_theme');
for (const event of events) {
try {
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Revert: set galleryLayout back to 'hero'
const revertedTheme = {
...theme,
galleryLayout: 'hero'
};
// Remove the new properties
delete revertedTheme.headerStyle;
delete revertedTheme.heroDividerStyle;
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(revertedTheme)
});
} catch (err) {
console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`);
}
}
// Remove the columns
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('header_style');
});
}
if (hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('hero_divider_style');
});
}
console.log('[Migration 065] Rollback completed');
};
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
}
};
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Check which columns already exist to make migration idempotent
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
// PostgreSQL error codes:
// - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename });
// Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`);
}
}
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.15",
"version": "3.3.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.15",
"version": "3.3.0-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -8040,9 +8040,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.1.1",
"version": "3.9.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"lint": "eslint src/"
},
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Script to generate pre-watermarked versions for existing photos
* This is a one-time migration script to populate watermarks for photos
* that existed before the pre-generation feature was implemented.
*
* Usage: node scripts/generate-watermarks.js [eventId]
*
* Options:
* eventId - Optional: Only generate watermarks for a specific event
*
* Examples:
* node scripts/generate-watermarks.js # Generate for all photos
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
*/
const path = require('path');
const { db } = require('../src/database/db');
const watermarkService = require('../src/services/watermarkService');
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
async function generateWatermarks(eventId = null) {
try {
console.log('='.repeat(60));
console.log('PicPeak Watermark Generation Script');
console.log('='.repeat(60));
// Check if watermarking is enabled
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
console.log('\nWatermarking is currently DISABLED in settings.');
console.log('Enable watermarking in Admin > Branding settings first.');
console.log('Exiting without generating watermarks.');
process.exit(0);
}
console.log('\nWatermark Settings:');
console.log(` Enabled: ${settings.enabled}`);
console.log(` Position: ${settings.position}`);
console.log(` Opacity: ${settings.opacity}%`);
console.log(` Size: ${settings.size}%`);
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNull('photos.watermark_path')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.id',
'photos.filename',
'photos.event_id',
'events.event_name'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`\nFiltering to event ID: ${eventId}`);
}
const photos = await query;
if (photos.length === 0) {
console.log('\nNo photos found without watermarks.');
if (eventId) {
console.log(`(Checked event ID: ${eventId})`);
}
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
process.exit(0);
}
console.log(`\nFound ${photos.length} photos without watermarks.`);
// Group by event for display
const eventCounts = {};
photos.forEach(p => {
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
});
console.log('\nPhotos by event:');
Object.entries(eventCounts).forEach(([name, count]) => {
console.log(` ${name}: ${count} photos`);
});
console.log('\nStarting watermark generation...\n');
let successCount = 0;
let failCount = 0;
const startTime = Date.now();
// Process photos with progress display
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const progress = Math.round(((i + 1) / photos.length) * 100);
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
try {
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
if (result.success) {
successCount++;
} else {
failCount++;
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
}
} catch (error) {
failCount++;
console.log(`\n Error: ${photo.filename} - ${error.message}`);
}
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n');
console.log('='.repeat(60));
console.log('Watermark Generation Complete');
console.log('='.repeat(60));
console.log(` Total processed: ${photos.length}`);
console.log(` Successful: ${successCount}`);
console.log(` Failed: ${failCount}`);
console.log(` Duration: ${duration} seconds`);
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
if (failCount > 0) {
console.log('\nSome watermarks failed to generate. Check the errors above.');
console.log('You can re-run this script to retry failed photos.');
}
process.exit(failCount > 0 ? 1 : 0);
} catch (error) {
console.error('\nFatal error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const eventId = args[0] ? parseInt(args[0], 10) : null;
if (args[0] && isNaN(eventId)) {
console.error('Error: eventId must be a number');
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
process.exit(1);
}
// Run the script
generateWatermarks(eventId)
.then(() => {
process.exit(0);
})
.catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
+6 -4
View File
@@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) {
.where('id', admin.id)
.update({
password_hash: passwordHash,
must_change_password: true,
updated_at: new Date()
});
// Password logging removed for security - check logs or database if needed
console.log('Password: [NEWLY RESET - stored in database]');
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. Save this password securely - it will not be shown again');
console.log('2. You will be required to change it on next login');
} else {
console.log('Password: [hidden - use --reset flag to generate new password]');
}
+1
View File
@@ -437,6 +437,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
+4 -3
View File
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
// Check if gallery has expired (only if expires_at is set)
// Galleries with null expires_at never expire
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
+258
View File
@@ -0,0 +1,258 @@
/**
* Admin Event Types Routes
* CRUD operations for managing customizable event types
*
* @module routes/adminEventTypes
*/
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventTypeService = require('../services/eventTypeService');
const logger = require('../utils/logger');
const router = express.Router();
/**
* GET /admin/event-types
* Get all event types (for admin management)
*/
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const includeInactive = req.query.includeInactive === 'true';
const eventTypes = await eventTypeService.getAllEventTypes({
activeOnly: !includeInactive
});
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/active
* Get only active event types (for dropdowns/selection)
*/
router.get('/active', adminAuth, async (req, res) => {
try {
const eventTypes = await eventTypeService.getActiveEventTypes();
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching active event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/:id
* Get a single event type by ID
*/
router.get('/:id', adminAuth, requirePermission('settings.view'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
if (!eventType) {
return res.status(404).json({ error: 'Event type not found' });
}
res.json(eventType);
} catch (error) {
logger.error('Error fetching event type:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event type' });
}
});
/**
* POST /admin/event-types
* Create a new event type
*/
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().trim().withMessage('Name is required'),
body('slug_prefix')
.notEmpty()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = req.body;
const eventType = await eventTypeService.createEventType({
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
});
// Log activity
await logActivity('event_type_created',
{ name, slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.status(201).json(eventType);
} catch (error) {
logger.error('Error creating event type:', { error: error.message });
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to create event type' });
}
});
/**
* PUT /admin/event-types/:id
* Update an event type
*/
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID'),
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
body('slug_prefix')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 }),
body('is_active').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = req.body;
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
// Log activity
await logActivity('event_type_updated',
{ id, changes: Object.keys(updates) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(eventType);
} catch (error) {
logger.error('Error updating event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to update event type' });
}
});
/**
* DELETE /admin/event-types/:id
* Delete an event type (only non-system types with no events)
*/
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const result = await eventTypeService.deleteEventType(parseInt(id));
// Log activity
await logActivity('event_type_deleted',
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error('Error deleting event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to delete event type' });
}
});
/**
* POST /admin/event-types/reorder
* Reorder event types by providing an array of IDs in the desired order
*/
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { orderedIds } = req.body;
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
// Log activity
await logActivity('event_types_reordered',
{ newOrder: orderedIds },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ eventTypes, message: 'Event types reordered successfully' });
} catch (error) {
logger.error('Error reordering event types:', { error: error.message });
res.status(500).json({ error: 'Failed to reorder event types' });
}
});
module.exports = router;
+3 -1
View File
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation
// Note: This is a partial/reference file - dynamic event type validation should be implemented
// similar to adminEvents.js using eventTypeService.isValidEventType()
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('customer_email').isEmail().normalizeEmail(),
+227 -25
View File
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const multer = require('multer');
const { archiveEvent } = require('../services/archiveService');
const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity');
@@ -17,6 +18,37 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for event logo uploads
const eventLogoStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
}
});
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
}
}
});
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
@@ -25,14 +57,18 @@ const getEventFieldRequirements = async () => {
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
@@ -47,6 +83,8 @@ const getEventFieldRequirements = async () => {
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
@@ -55,7 +93,9 @@ const getEventFieldRequirements = async () => {
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
@@ -104,9 +144,15 @@ const hasCustomerContactColumns = async () => {
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('event_date').optional().isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
@@ -143,7 +189,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none'])
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -182,7 +235,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [
moderate_comments = true,
show_feedback_to_guests = true,
// CSS Template
css_template_id = null
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
hero_logo_size = 'medium',
hero_logo_position = 'top',
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -201,6 +261,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
@@ -239,16 +302,23 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get event type info for slug generation
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
// Generate unique slug
const processedEventName = event_name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
@@ -264,15 +334,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// Parse YYYY-MM-DD format as local date to avoid timezone issues
let expires_at;
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(event_date);
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -285,7 +360,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
slug,
event_type,
event_name,
event_date,
event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
@@ -295,7 +370,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
@@ -305,7 +380,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null
css_template_id: css_template_id || null,
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
hero_logo_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top',
header_style: header_style || 'standard',
hero_divider_style: hero_divider_style || 'wave'
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -350,7 +430,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
status: 'pending',
@@ -367,7 +447,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
});
} catch (error) {
@@ -582,7 +662,14 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
}
return true;
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none'])
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -694,6 +781,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
}
// Update event
await db('events')
.where('id', id)
@@ -759,7 +851,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivePath = path.join(storagePath, event.archive_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(archivePath);
@@ -768,6 +860,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
// Don't fail the transaction if file deletion fails
}
}
// Delete custom event logo if exists
if (event.hero_logo_path) {
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(event.hero_logo_path);
} catch (err) {
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
}
}
});
// Log activity (outside transaction)
@@ -927,7 +1029,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
// For resending creation email, we need the actual password
// First, try to get it from the request body if provided
let galleryPassword = req.body.password;
// Use optional chaining to handle cases where req.body might be undefined
let galleryPassword = req.body?.password;
// If no password provided, we can't decrypt the existing one
// So we'll show a security message
@@ -1094,4 +1197,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
}
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!req.file) {
return res.status(400).json({ error: 'No logo file provided' });
}
// Delete old logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
}
}
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
const logoPath = req.file.path;
await db('events')
.where('id', id)
.update({
hero_logo_url: logoUrl,
hero_logo_path: logoPath
});
await logActivity('event_logo_uploaded',
{ eventName: event.event_name, filename: req.file.filename },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
});
} catch (error) {
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to upload event logo' });
}
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Delete logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
}
}
await db('events')
.where('id', id)
.update({
hero_logo_url: null,
hero_logo_path: null
});
await logActivity('event_logo_removed',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to delete event logo' });
}
});
module.exports = router;
+4 -4
View File
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
@@ -84,8 +84,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
'photos.comment_count',
'photos.width',
'photos.height',
'photos.created_at',
'categories.name as category_name'
'photos.uploaded_at',
'photo_categories.name as category_name'
),
eventId
);
+40 -14
View File
@@ -12,6 +12,7 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const router = express.Router();
// Get storage path from environment or default
@@ -175,8 +176,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
return res.status(400).json({ error: 'No files uploaded' });
}
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
// Determine photo type and category name
let photoType = 'individual'; // default
@@ -256,6 +258,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
const photoData = {
event_id: parseInt(eventId),
filename: newFilename,
original_filename: file.originalname, // Preserve original filename for Lightroom export
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
@@ -308,7 +311,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
@@ -319,6 +322,14 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
// Queue watermark generation in background (non-blocking)
// This pre-generates watermarked versions for fast serving in lightbox
if (insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
watermarkGeneratorService.generateForPhoto(photoId)
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
}
// Add to successful uploads
uploadedPhotos.push({
@@ -461,7 +472,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
}
}
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
// Remove from database
await db('photos').where({ id: photoId }).delete();
@@ -582,8 +598,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
}
}
}
// Delete pre-generated watermark
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
}
// Delete from database
await db('photos')
.whereIn('id', photoIds)
@@ -626,9 +647,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
}
// Prepare update data
const updateData = {
updated_at: new Date()
};
const updateData = {};
if (updates.category_id !== undefined) {
// Handle type-based categories ('individual' or 'collage')
@@ -705,13 +724,20 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
if (category_id === '' || category_id === '0') {
// For backwards compatibility, empty category means no filter
// Don't filter anything
} else if (category_id === 'individual' || category_id === 'collage') {
// Filter by category_id
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
if (category_id === 'individual' || category_id === 'collage') {
// Legacy type-based filtering
query = query.where({ 'photos.type': category_id });
} else if (category_id === 'uncategorized') {
// Filter for photos with no category assigned
query = query.whereNull('photos.category_id');
} else {
// Numeric category ID from photo_categories table
const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) {
query = query.where({ 'photos.category_id': numericCategoryId });
}
}
}
+111 -27
View File
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -100,13 +102,20 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -127,17 +136,24 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
const settings = await db('app_settings')
.where('setting_type', type)
.select('*');
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -196,6 +212,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
hide_powered_by
} = req.body;
// Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash();
const brandingSettings = {
company_name,
company_tagline,
@@ -306,7 +325,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
res.json({ message: 'Branding settings updated successfully' });
// Check if watermark settings changed and trigger regeneration
const newSettingsHash = await watermarkService.getSettingsHash();
let watermarkRegenerationStarted = false;
if (oldSettingsHash !== newSettingsHash) {
// Clear watermark cache
watermarkService.clearCache();
// Check if watermarking is now enabled or settings changed
const currentSettings = await watermarkService.getWatermarkSettings();
if (currentSettings && currentSettings.enabled) {
// Start background regeneration of all watermarks
console.log('Watermark settings changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
} else {
// Watermarking was disabled, clear all pre-generated watermarks
console.log('Watermarking disabled, clearing pre-generated watermarks');
watermarkGeneratorService.clearAllWatermarks()
.catch(err => console.error('Failed to clear watermarks:', err));
}
}
res.json({
message: 'Branding settings updated successfully',
watermarkRegenerationStarted
});
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -326,8 +378,12 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
.first();
if (oldLogoSetting && oldLogoSetting.setting_value) {
const oldPath = JSON.parse(oldLogoSetting.setting_value);
try {
// Handle both JSON-serialized and legacy raw path values
let oldPath = oldLogoSetting.setting_value;
if (oldPath.startsWith('"')) {
oldPath = JSON.parse(oldPath);
}
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old logo:', error);
@@ -355,13 +411,13 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
updated_at: new Date()
});
@@ -388,11 +444,21 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
.first();
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
let oldPath;
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
// Try to parse as JSON first (for JSON-stringified paths)
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
oldPath = oldWatermarkLogoSetting.setting_value;
}
if (oldPath && typeof oldPath === 'string') {
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
}
}
}
@@ -417,19 +483,37 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_url',
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
updated_at: new Date()
});
res.json({
// Trigger watermark regeneration since the logo changed
watermarkService.clearCache();
const currentSettings = await watermarkService.getWatermarkSettings();
let watermarkRegenerationStarted = false;
if (currentSettings && currentSettings.enabled) {
console.log('Watermark logo changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
}
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
watermarkLogoUrl: publicPath,
watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
@@ -891,13 +975,13 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
await db('app_settings')
.insert({
setting_key: 'branding_favicon_url',
setting_value: faviconUrl,
setting_value: JSON.stringify(faviconUrl),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: faviconUrl,
setting_value: JSON.stringify(faviconUrl),
updated_at: new Date()
});
+32 -2
View File
@@ -7,6 +7,7 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const router = express.Router();
// Get system version
@@ -22,12 +23,15 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
} catch (err) {
console.error('Could not read package.json:', err);
}
const channel = getCurrentChannel(backendVersion);
res.json({
backend: backendVersion,
frontend: '1.0.0', // This will be set by frontend
node: process.version,
environment: process.env.NODE_ENV || 'production'
environment: process.env.NODE_ENV || 'production',
channel: channel
});
} catch (error) {
console.error('Error fetching version:', error);
@@ -35,6 +39,32 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
}
});
// Check for updates
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({
enabled: false,
message: 'Update checking is disabled'
});
}
const forceRefresh = req.query.refresh === 'true';
const updateInfo = await checkForUpdates(forceRefresh);
res.json({
enabled: true,
...updateInfo
});
} catch (error) {
logger.error('Error checking for updates:', error);
res.status(500).json({ error: 'Failed to check for updates' });
}
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
+15 -1
View File
@@ -45,6 +45,20 @@ function transformRole(role) {
};
}
/**
* Transform invitation object from snake_case (DB) to camelCase (API)
*/
function transformInvitation(invitation) {
return {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expires_at,
createdAt: invitation.created_at,
roleName: invitation.role_name,
invitedBy: invitation.invited_by
};
}
/**
* GET /me/permissions
* Get current user's permissions
@@ -81,7 +95,7 @@ router.get('/roles', adminAuth, requirePermission('users.view'), handleAsync(asy
*/
router.get('/invitations', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
const invitations = await userManagementService.getPendingInvitations();
res.json({ invitations });
res.json({ invitations: invitations.map(transformInvitation) });
}));
/**
+8 -1
View File
@@ -11,6 +11,7 @@ const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
+129 -30
View File
@@ -5,6 +5,7 @@ const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -12,9 +13,10 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail } = require('../services/imageProcessor');
// 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');
// Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) {
@@ -106,13 +108,20 @@ router.get('/:slug/info', async (req, res) => {
'share_link',
'share_token',
'allow_downloads',
'allow_user_uploads',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme',
'enable_devtools_protection',
'use_canvas_rendering'
'use_canvas_rendering',
'hero_logo_visible',
'hero_logo_size',
'hero_logo_position',
'hero_logo_url',
'header_style',
'hero_divider_style'
)
.first();
@@ -150,15 +159,22 @@ router.get('/:slug/info', async (req, res) => {
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
hero_logo_size: event.hero_logo_size || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -171,7 +187,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// First get all photos
let photos = await db('photos')
.where('photos.event_id', req.event.id)
@@ -316,25 +338,34 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
expires_at: req.event.expires_at,
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
hero_logo_size: req.event.hero_logo_size || 'medium',
hero_logo_position: req.event.hero_logo_position || 'top',
hero_logo_url: req.event.hero_logo_url || null,
header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave',
...protectionSettings
},
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
// Add watermark version to URLs for cache busting when settings change
const wmQuery = wmVersion ? `?${wmVersion}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -343,6 +374,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
category_slug: photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Image dimensions for layout calculations
width: photo.width || null,
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// Feedback data
@@ -753,13 +787,54 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const fs = require('fs');
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
// Try to serve pre-generated watermarked file for instant loading
if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try {
const fs = require('fs');
// Check if pre-generated watermark file exists
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
return res.sendFile(watermarkFilePath);
}
} catch (err) {
// File doesn't exist or error, fall through to on-the-fly generation
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
}
}
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
// Also queue regeneration for next time
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id)
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -768,6 +843,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -787,30 +863,30 @@ router.get('/:slug/photo/:photoId',
);
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
const thumbPath = path.join(getStoragePath(), thumbnailPath);
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
@@ -818,18 +894,41 @@ router.get('/:slug/thumbnail/:photoId',
req.clientInfo,
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
const fs = require('fs');
const stat = fs.statSync(thumbPath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true'
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
// Send file
res.sendFile(path.resolve(thumbPath));
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
res.send(watermarkedBuffer);
} else {
// Send file without watermark
res.sendFile(path.resolve(thumbPath));
}
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
+36 -24
View File
@@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
}, 'view');
// Get protection settings from event
const eventProtectionLevel = req.event.protection_level || protectionLevel;
const protectionSettings = {
protectionLevel: req.event.protection_level || protectionLevel,
protectionLevel: eventProtectionLevel,
quality: req.event.image_quality || 85,
addFingerprint: req.event.add_fingerprint !== false,
fragmentImage: protectionLevel === 'maximum'
fragmentImage: eventProtectionLevel === 'maximum'
};
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
// Process image with protection
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
// Apply watermark if enabled
// For basic/standard protection without special features, serve original file
// This avoids unnecessary recompression
const needsProcessing = eventProtectionLevel === 'enhanced' ||
eventProtectionLevel === 'maximum' ||
protectionSettings.addFingerprint;
let finalImage;
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
if (!needsProcessing) {
// Serve original file without processing
const fs = require('fs').promises;
finalImage = await fs.readFile(photoPath);
} else {
const watermarkSettings = await watermarkService.getWatermarkSettings();
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Process image with protection measures
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
}
finalImage = processedImage;
}
// Set security headers
+4 -1
View File
@@ -58,6 +58,7 @@ router.get('/', async (req, res) => {
branding_logo_display_header: settingsObject.branding_logo_display_header !== false,
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
@@ -73,7 +74,9 @@ router.get('/', async (req, res) => {
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false
event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== false
};
res.json(publicSettings);
+5 -1
View File
@@ -993,12 +993,16 @@ async function getBackupStatus(limit = 10) {
}
}
const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null;
return {
isRunning,
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
lastRun: lastRunWithManifest,
lastBackup: lastRunWithManifest, // Alias for frontend compatibility
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
totalBackups: runs.filter(r => r.status === 'completed').length,
nextScheduledRun: getNextScheduledRun()
};
} catch (error) {
+14 -3
View File
@@ -14,8 +14,9 @@ const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Cache for schema detection
let customerColumnCache = null;
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
/**
* Generate a unique slug for an event
* @param {string} eventType
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
* @param {string} eventName
* @param {string} eventDate
* @returns {Promise<string>}
*/
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
const processedName = eventName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
let slug = baseSlug;
let counter = 1;
+314
View File
@@ -0,0 +1,314 @@
/**
* Event Type Service Layer
* Handles all event type-related business logic
*
* @module services/eventTypeService
*/
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
/**
* Get all event types
* @param {Object} options - Filter options
* @param {boolean} options.activeOnly - Only return active types
* @returns {Promise<Array>} - Array of event types
*/
const getAllEventTypes = async (options = {}) => {
const { activeOnly = false } = options;
let query = db('event_types').select('*');
if (activeOnly) {
query = query.where('is_active', formatBoolean(true));
}
const types = await query.orderBy('display_order', 'asc');
return types;
};
/**
* Get active event types for dropdown/selection
* @returns {Promise<Array>} - Array of active event types
*/
const getActiveEventTypes = async () => {
return getAllEventTypes({ activeOnly: true });
};
/**
* Get an event type by ID
* @param {number} id - Event type ID
* @returns {Promise<Object|null>}
*/
const getEventTypeById = async (id) => {
const eventType = await db('event_types').where('id', id).first();
return eventType || null;
};
/**
* Get an event type by slug prefix
* @param {string} slugPrefix - The slug prefix
* @returns {Promise<Object|null>}
*/
const getEventTypeBySlugPrefix = async (slugPrefix) => {
const eventType = await db('event_types')
.where('slug_prefix', slugPrefix.toLowerCase())
.first();
return eventType || null;
};
/**
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
* @param {string} slugPrefix - The slug prefix to validate
* @returns {Promise<boolean>}
*/
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// Check in database
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType && eventType.is_active) {
return true;
}
// Legacy fallback: Accept old hardcoded values for backward compatibility
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
/**
* Get all valid slug prefixes (for validation)
* @returns {Promise<string[]>}
*/
const getValidSlugPrefixes = async () => {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.select('slug_prefix');
return types.map(t => t.slug_prefix);
};
/**
* Create a new event type
* @param {Object} eventTypeData - Event type data
* @returns {Promise<Object>} - Created event type
*/
const createEventType = async (eventTypeData) => {
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = eventTypeData;
// Normalize slug_prefix
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate slug_prefix
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
// Get max display order if not provided
let finalDisplayOrder = display_order;
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
const maxOrder = await db('event_types').max('display_order as max').first();
finalDisplayOrder = (maxOrder?.max || 0) + 1;
}
const insertData = {
name,
slug_prefix: normalizedSlugPrefix,
emoji: emoji || '📷',
theme_preset: theme_preset || 'default',
theme_config: theme_config ? JSON.stringify(theme_config) : null,
display_order: finalDisplayOrder,
is_system: false,
is_active: true,
created_at: new Date(),
updated_at: new Date()
};
const insertResult = await db('event_types').insert(insertData).returning('id');
const eventTypeId = insertResult[0]?.id || insertResult[0];
return getEventTypeById(eventTypeId);
};
/**
* Update an event type
* @param {number} id - Event type ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} - Updated event type
*/
const updateEventType = async (id, updates) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Build update object
const updateData = {};
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.slug_prefix !== undefined) {
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate (excluding current)
const existing = await db('event_types')
.where('slug_prefix', normalizedSlugPrefix)
.whereNot('id', id)
.first();
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
updateData.slug_prefix = normalizedSlugPrefix;
}
if (updates.emoji !== undefined) {
updateData.emoji = updates.emoji;
}
if (updates.theme_preset !== undefined) {
updateData.theme_preset = updates.theme_preset;
}
if (updates.theme_config !== undefined) {
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
}
if (updates.display_order !== undefined) {
updateData.display_order = updates.display_order;
}
if (updates.is_active !== undefined) {
updateData.is_active = formatBoolean(updates.is_active);
}
updateData.updated_at = new Date();
await db('event_types').where('id', id).update(updateData);
return getEventTypeById(id);
};
/**
* Delete an event type
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
const deleteEventType = async (id) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Prevent deletion of system types
if (eventType.is_system) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
// Check if any events use this type
const eventsUsingType = await db('events')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
error.code = 'IN_USE';
throw error;
}
await db('event_types').where('id', id).del();
return { success: true, deleted: eventType };
};
/**
* Reorder event types
* @param {Array} orderedIds - Array of IDs in new order
* @returns {Promise<Array>}
*/
const reorderEventTypes = async (orderedIds) => {
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i++) {
await trx('event_types')
.where('id', orderedIds[i])
.update({ display_order: i + 1, updated_at: new Date() });
}
});
return getAllEventTypes();
};
/**
* Get event type info for slug generation
* Returns the slug_prefix to use for a given event type identifier
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
*/
const getEventTypeForSlug = async (eventTypeIdentifier) => {
// Try to find by slug_prefix first
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
if (eventType) {
return eventType;
}
// Try by ID if numeric
if (!isNaN(eventTypeIdentifier)) {
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
if (eventType) {
return eventType;
}
}
// Fallback for legacy types - return a compatible object
const legacyDefaults = {
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
};
const normalized = eventTypeIdentifier.toLowerCase();
if (legacyDefaults[normalized]) {
return legacyDefaults[normalized];
}
// Default fallback
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
getEventTypeById,
getEventTypeBySlugPrefix,
isValidEventType,
getValidSlugPrefixes,
createEventType,
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug
};
@@ -21,9 +21,11 @@ async function checkExpirations() {
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -40,9 +42,11 @@ async function checkExpirations() {
}
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
+19 -19
View File
@@ -23,13 +23,13 @@ class PhotoExportService {
*/
async getPhotosWithFeedback(eventId, photoIds = null) {
let query = db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', eventId)
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
@@ -37,9 +37,9 @@ class PhotoExportService {
'photos.comment_count',
'photos.width',
'photos.height',
'photos.file_size',
'photos.created_at',
'categories.name as category_name'
'photos.size_bytes',
'photos.uploaded_at',
'photo_categories.name as category_name'
)
.orderBy('photos.filename', 'asc');
@@ -86,7 +86,7 @@ class PhotoExportService {
const { filename_format = 'original', separator = 'newline' } = options;
const filenames = photos.map(photo =>
filename_format === 'original' ? photo.original_filename : photo.filename
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
);
let content;
@@ -126,14 +126,14 @@ class PhotoExportService {
'category',
'width',
'height',
'file_size',
'created_at'
'size_bytes',
'uploaded_at'
];
const rows = photos.map(photo => [
filename_format === 'original' ? photo.original_filename : photo.filename,
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
photo.original_filename || '',
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
photo.feedback_count || 0,
photo.like_count || 0,
photo.favorite_count || 0,
@@ -141,8 +141,8 @@ class PhotoExportService {
photo.category_name || '',
photo.width || '',
photo.height || '',
photo.file_size || '',
photo.created_at ? new Date(photo.created_at).toISOString() : ''
photo.size_bytes || '',
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
]);
const csvContent = [
@@ -170,7 +170,7 @@ class PhotoExportService {
for (const photo of photos) {
const baseFilename = filename_format === 'original'
? photo.original_filename
? (photo.original_filename || photo.filename)
: photo.filename;
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
@@ -209,21 +209,21 @@ class PhotoExportService {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
original_filename: photo.original_filename,
original_filename: photo.original_filename || null,
category: photo.category_name || null,
rating: {
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
count: photo.feedback_count || 0
},
likes: photo.like_count || 0,
favorites: photo.favorite_count || 0,
comments: photo.comment_count || 0,
dimensions: {
width: photo.width,
height: photo.height
width: photo.width || null,
height: photo.height || null
},
file_size: photo.file_size,
created_at: photo.created_at
size_bytes: photo.size_bytes || null,
uploaded_at: photo.uploaded_at || null
}))
};
+23 -1
View File
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate thumbnail and extract metadata
let thumbnailPath;
let videoMetadata = null;
let imageMetadata = null;
if (isVideo) {
// Process video: extract metadata and generate thumbnail
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
videoMetadata = result.metadata;
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
} else {
// Process image: generate thumbnail
// Process image: generate thumbnail and extract dimensions
thumbnailPath = await generateThumbnail(newPath);
// Extract image dimensions using sharp
try {
const sharp = require('sharp');
const metadata = await sharp(newPath).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
}
} catch (metadataError) {
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
}
}
// Calculate relative paths
@@ -186,6 +201,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const photoData = {
event_id: eventId,
filename: newFilename,
original_filename: file.originalname,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
@@ -205,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
photoData.height = videoMetadata.height;
}
// Add image dimensions if available
if (!isVideo && imageMetadata) {
photoData.width = imageMetadata.width;
photoData.height = imageMetadata.height;
}
if (supportsReturning) {
insertResult = await trx('photos')
.insert(photoData)
+1 -1
View File
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Get photos for an event with optional filtering
+64 -7
View File
@@ -157,6 +157,8 @@ class SecureImageService {
/**
* Process image with protection measures
* For basic/standard protection without fingerprinting, returns original file
* For enhanced/maximum protection, applies quality reduction and fingerprinting
*/
async processProtectedImage(imagePath, options = {}) {
const {
@@ -169,11 +171,58 @@ class SecureImageService {
} = options;
try {
// For basic protection level, always return original file without processing
if (protectionLevel === 'basic') {
return await fs.readFile(imagePath);
}
// For standard protection without fingerprinting, return original file
// This avoids unnecessary recompression when no protection features are needed
if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) {
return await fs.readFile(imagePath);
}
// Get metadata to check if processing is actually needed
const metadata = await sharp(imagePath).metadata();
// For standard protection with fingerprint only (no resize needed, no quality change),
// we can add fingerprint without full recompression by preserving format
const needsResize = metadata.width > maxWidth || metadata.height > maxHeight;
const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum';
// If standard protection and only fingerprinting is needed, and image doesn't need resize,
// just add metadata without recompressing
if (protectionLevel === 'standard' && addFingerprint && !needsResize) {
let image = sharp(imagePath);
// Add fingerprint to metadata without changing image quality
const fingerprint = crypto.randomBytes(16).toString('hex');
// Preserve original format with high quality
const format = metadata.format || 'jpeg';
if (format === 'png') {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: 95 });
} else {
image = image.jpeg({ quality: 100, mozjpeg: true });
}
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
});
return await image.toBuffer();
}
// For enhanced/maximum protection or when resize is needed, do full processing
let image = sharp(imagePath);
const metadata = await image.metadata();
let effectiveQuality = quality;
// Resize if too large
if (metadata.width > maxWidth || metadata.height > maxHeight) {
if (needsResize) {
image = image.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
@@ -182,18 +231,26 @@ class SecureImageService {
// Apply quality reduction for protection
if (protectionLevel === 'enhanced') {
quality = Math.min(quality, 70);
effectiveQuality = Math.min(quality, 70);
} else if (protectionLevel === 'maximum') {
quality = Math.min(quality, 60);
effectiveQuality = Math.min(quality, 60);
}
// Convert to appropriate format
image = image.jpeg({ quality, progressive: true });
// Preserve original format when possible, apply quality settings
const format = metadata.format || 'jpeg';
if (format === 'png' && !needsQualityReduction) {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: effectiveQuality });
} else {
// JPEG or when quality reduction is needed (convert to JPEG)
image = image.jpeg({ quality: effectiveQuality, progressive: true });
}
// Add invisible watermark/fingerprint
if (addFingerprint) {
const fingerprint = crypto.randomBytes(16).toString('hex');
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
+212
View File
@@ -0,0 +1,212 @@
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
// Cache for version info (avoid hitting GitHub API too often)
let versionCache = null;
let lastCheck = 0;
const CACHE_TTL = 60 * 60 * 1000; // 1 hour cache
/**
* Get current installed version from package.json
*/
async function getCurrentVersion() {
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = await fs.readFile(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
return packageJson.version || '0.0.0';
} catch (err) {
logger.error('Could not read package.json for version:', err);
return '0.0.0';
}
}
/**
* Determine current release channel from version or environment
*/
function getCurrentChannel(version) {
// Check environment variable first
const envChannel = process.env.PICPEAK_RELEASE_CHANNEL;
if (envChannel && ['stable', 'beta'].includes(envChannel)) {
return envChannel;
}
// Infer from version string
if (version && version.includes('-beta')) {
return 'beta';
}
return 'stable';
}
/**
* Parse version string into comparable parts
*/
function parseVersion(version) {
if (!version) return null;
// Handle versions like "2.3.0" or "2.3.0-beta.1"
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
beta: match[4] ? parseInt(match[4], 10) : null,
isBeta: !!match[4]
};
}
/**
* Compare two versions
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
function compareVersions(a, b) {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return 0;
// Compare major.minor.patch
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
// Handle beta vs stable
if (va.isBeta && !vb.isBeta) return -1; // beta < stable
if (!va.isBeta && vb.isBeta) return 1; // stable > beta
// Both are beta - compare beta numbers
if (va.isBeta && vb.isBeta) {
if (va.beta !== vb.beta) return va.beta > vb.beta ? 1 : -1;
}
return 0;
}
/**
* Fetch available versions from GitHub Releases
* Uses GitHub Releases API which is publicly accessible without authentication
*/
async function fetchAvailableVersions() {
try {
// Use GitHub Releases API (public, no auth required)
const response = await axios.get(
'https://api.github.com/repos/the-luap/picpeak/releases',
{
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'PicPeak-Update-Checker'
},
timeout: 10000
}
);
// Extract version tags from releases
const versions = {
stable: [],
beta: []
};
for (const release of response.data) {
const tag = release.tag_name;
if (!tag) continue;
// Remove 'v' prefix if present
const version = tag.startsWith('v') ? tag.substring(1) : tag;
if (version.match(/^\d+\.\d+\.\d+$/)) {
// Stable version
versions.stable.push(version);
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
// Beta version
versions.beta.push(version);
}
}
// Sort versions descending (newest first)
versions.stable.sort((a, b) => compareVersions(b, a));
versions.beta.sort((a, b) => compareVersions(b, a));
return versions;
} catch (error) {
logger.error('Failed to fetch available versions from GitHub:', error.message);
return null;
}
}
/**
* Check for available updates
*/
async function checkForUpdates(forceRefresh = false) {
const now = Date.now();
// Use cache if available and not expired
if (!forceRefresh && versionCache && (now - lastCheck) < CACHE_TTL) {
return versionCache;
}
const currentVersion = await getCurrentVersion();
const currentChannel = getCurrentChannel(currentVersion);
const availableVersions = await fetchAvailableVersions();
if (!availableVersions) {
return {
current: currentVersion,
channel: currentChannel,
updateAvailable: false,
error: 'Unable to check for updates'
};
}
// Determine latest version for current channel
const latestStable = availableVersions.stable[0] || currentVersion;
const latestBeta = availableVersions.beta[0] || currentVersion;
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
// Also check if there's a newer beta for stable users who want to preview
const newerBetaAvailable = currentChannel === 'stable' &&
availableVersions.beta.length > 0 &&
compareVersions(latestBeta, currentVersion) > 0;
const result = {
current: currentVersion,
channel: currentChannel,
latest: {
stable: latestStable,
beta: latestBeta,
forChannel: latestForChannel
},
updateAvailable,
newerBetaAvailable,
lastChecked: new Date().toISOString()
};
// Update cache
versionCache = result;
lastCheck = now;
return result;
}
/**
* Clear the version cache (useful for testing)
*/
function clearCache() {
versionCache = null;
lastCheck = 0;
}
module.exports = {
checkForUpdates,
getCurrentVersion,
getCurrentChannel,
compareVersions,
parseVersion,
clearCache
};
@@ -60,7 +60,7 @@ async function createInvitation({ email, roleId, invitedById }) {
// Queue invitation email
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
invite_link: `${frontendUrl}/invite/${token}`,
role_name: role.display_name,
expires_at: expiresAt.toISOString()
});
@@ -0,0 +1,433 @@
/**
* WatermarkGeneratorService
*
* Handles batch generation of pre-watermarked images for fast serving.
* This service is responsible for:
* - Generating watermarks for newly uploaded photos
* - Regenerating all watermarks when settings change
* - Tracking regeneration progress
*/
const path = require('path');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { getStoragePath } = require('../config/storage');
class WatermarkGeneratorService {
constructor() {
// Track active regeneration jobs
this.activeJobs = new Map();
// Batch size for processing (to manage memory)
this.batchSize = 10;
// Concurrent processing limit
this.concurrentLimit = 2;
}
/**
* Generate watermark for a single photo
* @param {number} photoId - The photo ID
* @returns {Object} Result with success status and watermark path
*/
async generateForPhoto(photoId) {
try {
// Get photo with event info
const photo = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.id', photoId)
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
)
.first();
if (!photo) {
return { success: false, error: 'Photo not found' };
}
// Skip video files
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
return { success: false, error: 'Videos do not support watermarks' };
}
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { success: false, error: 'Watermarking is disabled' };
}
// Resolve the original file path
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, error: 'Could not resolve photo path' };
}
// Generate and save watermark
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
// Update database with watermark path
await db('photos')
.where({ id: photoId })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return result;
} catch (error) {
console.error(`Error generating watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Resolve the full file path for a photo
*/
resolvePhotoPath(photo) {
const storagePath = getStoragePath();
// Handle external/reference mode
if (photo.source_mode === 'reference' && photo.external_relpath) {
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
}
// Standard managed mode
if (photo.file_path) {
// file_path might be absolute or relative
if (path.isAbsolute(photo.file_path)) {
return photo.file_path;
}
return path.join(storagePath, photo.file_path);
}
// Fallback to constructing path from slug and filename
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
}
/**
* Generate watermarks for all photos in an event
* @param {number} eventId - The event ID
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async generateForEvent(eventId, onProgress = null) {
const results = { total: 0, success: 0, failed: 0, errors: [] };
try {
// Get all photos for the event (excluding videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.event_id', eventId)
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
return results;
}
// Get watermark settings once
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { ...results, errors: ['Watermarking is disabled'] };
}
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Progress callback
if (onProgress) {
onProgress({
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed
});
}
}
return results;
} catch (error) {
console.error(`Error generating watermarks for event ${eventId}:`, error);
return { ...results, errors: [...results.errors, error.message] };
}
}
/**
* Process watermark for a single photo (internal helper)
*/
async processPhotoWatermark(photo, settings) {
try {
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
}
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
await db('photos')
.where({ id: photo.id })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return { ...result, photoId: photo.id };
} catch (error) {
return { success: false, photoId: photo.id, error: error.message };
}
}
/**
* Regenerate watermarks for all photos in the system
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async regenerateAll(onProgress = null) {
const jobId = Date.now().toString();
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
try {
this.activeJobs.set(jobId, results);
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
results.status = 'completed';
results.errors.push('Watermarking is disabled');
return results;
}
// First, clear existing watermarks from DB (the files will be overwritten)
// This ensures stale paths don't persist if regeneration fails
// Get all image photos (exclude videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
results.status = 'completed';
return results;
}
console.log(`Starting watermark regeneration for ${photos.length} photos`);
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
// Check if job was cancelled
if (!this.activeJobs.has(jobId)) {
results.status = 'cancelled';
return results;
}
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error && results.errors.length < 50) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Update job status
this.activeJobs.set(jobId, { ...results });
// Progress callback
if (onProgress) {
onProgress({
jobId,
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed,
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
});
}
// Small delay between batches to prevent CPU saturation
await new Promise(resolve => setTimeout(resolve, 100));
}
results.status = 'completed';
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
return results;
} catch (error) {
console.error('Error during watermark regeneration:', error);
results.status = 'failed';
results.errors.push(error.message);
return results;
} finally {
// Clean up job tracking after a delay
setTimeout(() => {
this.activeJobs.delete(jobId);
}, 60000); // Keep for 1 minute for status queries
}
}
/**
* Clear all watermarks (when watermarking is disabled)
*/
async clearAllWatermarks() {
try {
// Get all photos with watermarks
const photos = await db('photos')
.whereNotNull('watermark_path')
.select('id', 'watermark_path');
// Delete watermark files
for (const photo of photos) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
}
// Clear database paths
await db('photos')
.whereNotNull('watermark_path')
.update({
watermark_path: null,
watermark_generated_at: null
});
console.log(`Cleared ${photos.length} watermarks`);
return { success: true, cleared: photos.length };
} catch (error) {
console.error('Error clearing watermarks:', error);
return { success: false, error: error.message };
}
}
/**
* Delete watermark for a specific photo
*/
async deleteForPhoto(photoId) {
try {
const photo = await db('photos')
.where({ id: photoId })
.select('watermark_path')
.first();
if (photo && photo.watermark_path) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
await db('photos')
.where({ id: photoId })
.update({
watermark_path: null,
watermark_generated_at: null
});
}
return { success: true };
} catch (error) {
console.error(`Error deleting watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Get status of an active regeneration job
*/
getJobStatus(jobId) {
return this.activeJobs.get(jobId) || null;
}
/**
* Cancel an active regeneration job
*/
cancelJob(jobId) {
if (this.activeJobs.has(jobId)) {
this.activeJobs.delete(jobId);
return true;
}
return false;
}
/**
* Check if there's an active regeneration job
*/
hasActiveJob() {
for (const [, job] of this.activeJobs) {
if (job.status === 'running') {
return true;
}
}
return false;
}
/**
* Get count of photos needing watermark generation
*/
async getPendingCount() {
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return 0;
}
const result = await db('photos')
.whereNull('watermark_path')
.whereNot(function() {
this.where('media_type', 'video')
.orWhere('mime_type', 'like', 'video/%');
})
.count('id as count')
.first();
return parseInt(result.count) || 0;
}
}
module.exports = new WatermarkGeneratorService();
+139 -8
View File
@@ -2,6 +2,7 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { getStoragePath } = require('../config/storage');
class WatermarkService {
constructor() {
@@ -177,14 +178,25 @@ class WatermarkService {
settings.position
);
// Apply watermark
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.toBuffer();
// Apply watermark with high quality output to preserve original image quality
let watermarkedImage = image.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}]);
// Preserve original format with high quality settings
const format = metadata.format || 'jpeg';
let watermarkedBuffer;
if (format === 'png') {
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
} else if (format === 'webp') {
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
} else {
// Default to JPEG with maximum quality (100) to prevent recompression
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
}
// Cache the result
this.cache.set(cacheKey, {
@@ -221,6 +233,125 @@ class WatermarkService {
clearCache() {
this.cache.clear();
}
/**
* Get the watermarks directory path, creating it if needed
*/
async getWatermarksDir() {
const watermarksDir = path.join(getStoragePath(), 'watermarks');
try {
await fs.access(watermarksDir);
} catch {
await fs.mkdir(watermarksDir, { recursive: true });
}
return watermarksDir;
}
/**
* Get the file extension from a filename
*/
getFileExtension(filename) {
const ext = path.extname(filename).toLowerCase();
// Map common extensions
if (ext === '.jpeg') return '.jpg';
return ext || '.jpg';
}
/**
* Generate watermarked version of a photo and save to disk
* @param {Object} photo - Photo object with id, filename, and path info
* @param {string} originalPath - Full path to the original image file
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
* @returns {Object} { success, watermarkPath, error }
*/
async generateAndSaveWatermark(photo, originalPath, settings = null) {
try {
// Get settings if not provided
if (!settings) {
settings = await this.getWatermarkSettings();
}
// If watermarking is disabled, return early
if (!settings || !settings.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
// Verify original file exists
try {
await fs.access(originalPath);
} catch {
return { success: false, watermarkPath: null, error: 'Original file not found' };
}
// Generate watermarked buffer using existing method
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
// Determine output path
const watermarksDir = await this.getWatermarksDir();
const ext = this.getFileExtension(photo.filename);
const outputFilename = `${photo.id}_watermarked${ext}`;
const outputPath = path.join(watermarksDir, outputFilename);
// Write the watermarked image to disk
await fs.writeFile(outputPath, watermarkedBuffer);
// Return relative path for database storage
const relativePath = `watermarks/${outputFilename}`;
return {
success: true,
watermarkPath: relativePath,
error: null
};
} catch (error) {
console.error(`Error generating watermark for photo ${photo.id}:`, error);
return {
success: false,
watermarkPath: null,
error: error.message
};
}
}
/**
* Delete a pre-generated watermark file
* @param {string} watermarkPath - Relative path to the watermark file
* @returns {boolean} - True if deleted successfully
*/
async deleteWatermarkFile(watermarkPath) {
if (!watermarkPath) return false;
try {
const fullPath = path.join(getStoragePath(), watermarkPath);
await fs.unlink(fullPath);
return true;
} catch (error) {
// File might not exist, which is fine
if (error.code !== 'ENOENT') {
console.error('Error deleting watermark file:', error);
}
return false;
}
}
/**
* Create a hash of current watermark settings for change detection
* @returns {string} - Hash string of settings
*/
async getSettingsHash() {
const settings = await this.getWatermarkSettings();
if (!settings) return '';
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
// Simple hash for change detection (not cryptographic)
let hash = 0;
for (let i = 0; i < hashData.length; i++) {
const char = hashData.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
}
}
module.exports = new WatermarkService();
+1 -1
View File
@@ -76,7 +76,7 @@ class XmpGenerator {
* @returns {string} Description XML
*/
generateDescription(photo) {
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
const rating = photo.average_rating ? parseFloat(photo.average_rating).toFixed(1) : '0';
const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0;
+6 -2
View File
@@ -38,14 +38,17 @@ services:
backend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/backend:latest
# PICPEAK_CHANNEL: 'stable' (default), 'beta', or specific version like 'v2.3.0'
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
container_name: picpeak-backend
env_file: .env
environment:
- NODE_ENV=production
- DB_HOST=${DB_HOST:-postgres}
- REDIS_HOST=redis
- STORAGE_PATH=/app/storage
- PHOTOS_DIR=/app/storage/events
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
volumes:
- ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs
@@ -69,7 +72,8 @@ services:
frontend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/frontend:latest
# Uses same channel as backend for consistency
image: ghcr.io/the-luap/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
container_name: picpeak-frontend
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS.
+3 -3
View File
@@ -7,7 +7,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=3001
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
@@ -43,12 +43,12 @@ services:
- ./backup:/backup
- ./storage:/app/storage
ports:
- "${BACKEND_PORT:-3001}:3001"
- "${BACKEND_PORT:-3001}:3000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3001/health"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 10s
retries: 3
+26 -14
View File
@@ -4,8 +4,12 @@ server {
root /usr/share/nginx/html;
index index.html;
# Allow larger file uploads (up to 100MB)
client_max_body_size 100M;
# Docker DNS resolver for dynamic service discovery (required for Swarm/Compose)
resolver 127.0.0.11 valid=10s ipv6=off;
resolver_timeout 5s;
# Allow larger file uploads (up to 1GB for video support)
client_max_body_size 1G;
client_body_timeout 300s;
# Gzip compression
@@ -43,7 +47,9 @@ server {
# API proxy
location /api {
proxy_pass http://backend:3001;
# Use variable to force DNS resolution per request (required for Docker Swarm)
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -53,21 +59,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
# Allow larger uploads for API endpoints
client_max_body_size 100M;
# Allow larger uploads for API endpoints (up to 1GB for video support)
client_max_body_size 1G;
client_body_timeout 300s;
}
# Photo serving proxy
location /photos {
proxy_pass http://backend:3001;
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache photos
proxy_cache_valid 200 302 1d;
proxy_cache_valid 404 1m;
@@ -75,27 +82,30 @@ server {
# Thumbnail serving proxy
location /thumbnails {
proxy_pass http://backend:3001;
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache thumbnails
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Uploads serving proxy (logos, favicons, watermarks)
location /uploads {
proxy_pass http://backend:3001;
# ^~ modifier stops regex matching, ensuring uploads are proxied not served locally
location ^~ /uploads {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache uploads
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
@@ -103,7 +113,9 @@ server {
# Delegate root requests to backend for public landing page handling
location = / {
proxy_pass http://backend:3001/;
# Use variable to force DNS resolution per request (required for Docker Swarm)
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
+46 -17
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.1.15",
"version": "3.3.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.1.15",
"version": "3.3.0-beta.0",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
@@ -27,6 +27,7 @@
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"justified-layout": "^4.1.0",
"linkifyjs": "^4.3.2",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
@@ -38,6 +39,7 @@
"react-i18next": "^15.6.0",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.8.0",
"react-toastify": "11.0.5",
"tailwind-merge": "^3.3.1"
@@ -1281,9 +1283,9 @@
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -4664,6 +4666,12 @@
"node": ">=6"
}
},
"node_modules/justified-layout": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz",
"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4740,9 +4748,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -5813,6 +5821,27 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-photo-album": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
"integrity": "sha512-pPaCoxEfVDhowpqxECq4SOD5kzP1Uao8PEN11Jasxayv4cZjad3Fy8SlKt6wvtLnVJRtOjsQDU/ZnnUuberwMg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/igordanchenko"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
"react": "^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5824,12 +5853,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1"
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -5839,13 +5868,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1",
"react-router": "6.30.2"
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.1.1",
"version": "3.9.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -31,6 +31,7 @@
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"justified-layout": "^4.1.0",
"linkifyjs": "^4.3.2",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
@@ -42,6 +43,7 @@
"react-i18next": "^15.6.0",
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.8.0",
"react-toastify": "11.0.5",
"tailwind-merge": "^3.3.1"
+3 -1
View File
@@ -24,7 +24,8 @@ import {
SettingsPage,
BackupManagement,
CMSPage,
UserManagementPage
UserManagementPage,
EventTypesPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
@@ -128,6 +129,7 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="event-types" element={<EventTypesPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,12 @@ import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { BulkCategoryModal } from './BulkCategoryModal';
interface CategoryOption {
id: number;
name: string;
}
interface AdminPhotoGridProps {
photos: AdminPhoto[];
@@ -14,6 +20,7 @@ interface AdminPhotoGridProps {
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
categories?: CategoryOption[];
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
@@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
eventId,
onPhotoClick,
onPhotosDeleted,
onSelectionChange
onSelectionChange,
categories = []
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
@@ -125,6 +135,35 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
}
};
const handleMoveToCategory = async (categoryId: number | null) => {
if (selectedPhotos.size === 0) return;
setIsUpdatingCategory(true);
const selectedIds = Array.from(selectedPhotos);
try {
await photosService.updatePhotosCategory(eventId, selectedIds, categoryId);
const categoryName = categoryId
? categories.find(c => Number(c.id) === categoryId)?.name || t('photos.selectedCategory', 'selected category')
: t('photos.uncategorized', 'Uncategorized');
toast.success(
t('photos.movedToCategory', '{{count}} photos moved to {{category}}', {
count: selectedIds.length,
category: categoryName
})
);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
setIsCategoryModalOpen(false);
onPhotosDeleted(); // Refresh the photo list
} catch {
toast.error(t('photos.moveToCategoryFailed', 'Failed to move photos to category'));
} finally {
setIsUpdatingCategory(false);
}
};
return (
<div>
{/* Action Bar */}
@@ -154,6 +193,14 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<span className="text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setIsCategoryModalOpen(true)}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -309,6 +356,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
{/* Bulk Category Modal */}
<BulkCategoryModal
isOpen={isCategoryModalOpen}
onClose={() => setIsCategoryModalOpen(false)}
onConfirm={handleMoveToCategory}
photoCount={selectedPhotos.size}
categories={categories}
isLoading={isUpdatingCategory}
/>
</div>
);
};
@@ -11,7 +11,8 @@ import {
Palette,
FileText,
HardDrive,
Users
Users,
Tags
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -39,6 +40,7 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { FolderOpen, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
interface CategoryOption {
id: number;
name: string;
}
interface BulkCategoryModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (categoryId: number | null) => Promise<void>;
photoCount: number;
categories: CategoryOption[];
isLoading: boolean;
}
export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
isOpen,
onClose,
onConfirm,
photoCount,
categories,
isLoading,
}) => {
const { t } = useTranslation();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
if (!isOpen) return null;
const handleConfirm = async () => {
await onConfirm(selectedCategoryId);
};
const handleClose = () => {
setSelectedCategoryId(null);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-6">
<label htmlFor="category-select" className="block text-sm font-medium text-neutral-700 mb-2">
{t('photos.selectCategory', 'Select category')}
</label>
<select
id="category-select"
value={selectedCategoryId ?? ''}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleConfirm}
isLoading={isLoading}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.movePhotos', 'Move Photos')}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkCategoryModal.displayName = 'BulkCategoryModal';
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
@@ -28,7 +28,7 @@ interface EventRenameDialogProps {
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
eventId: _eventId,
customerEmail,
onClose,
onRename,
@@ -151,18 +151,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div>
);
case 'hero':
return (
<div className="space-y-3">
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
<div className={`grid grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(1, 5).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
@@ -90,7 +90,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
{t(option.label, { defaultValue: option.label.split('.').pop() })}
</option>
))}
</select>
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
@@ -28,10 +28,45 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
hero: <Image className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />
};
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
hero: <Image className="w-5 h-5" />,
standard: <Layout className="w-5 h-5" />,
minimal: <Minimize2 className="w-5 h-5" />,
none: <EyeOff className="w-5 h-5" />
};
const dividerStylePreviews: Record<HeroDividerStyle, React.ReactNode> = {
wave: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,12 C12,18 37,6 50,12 C63,18 88,6 100,12 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
straight: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="12" width="100" height="12" fill="currentColor" className="text-neutral-300" />
</svg>
),
angle: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,24 L100,8 L100,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
curve: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,16 Q50,0 100,16 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
none: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="24" fill="currentColor" className="text-neutral-100" />
<text x="50" y="16" textAnchor="middle" fontSize="10" fill="currentColor" className="text-neutral-400">No divider</text>
</svg>
)
};
// Layout descriptions will use translation keys
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
@@ -67,15 +102,16 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
// When any change is made, mark it as custom
if (selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
if (isPreviewMode) {
onChange(updated);
// Include customCss in the propagated theme
onChange({ ...updated, customCss });
}
};
@@ -84,6 +120,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
setCustomCss(''); // Clear custom CSS when selecting a preset
if (onPresetChange) {
onPresetChange(presetKey);
}
@@ -364,6 +401,153 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</select>
</div>
)}
{/* Masonry specific */}
{localTheme.galleryLayout === 'masonry' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.masonryMode', 'Layout Mode')}
</label>
<select
value={localTheme.gallerySettings?.masonryMode || 'columns'}
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Custom justified)')}</option>
<option value="flickr">{t('branding.masonryModeOptions.flickr', 'Flickr (Battle-tested justified)')}</option>
<option value="justified">{t('branding.masonryModeOptions.justified', 'Google Photos (Knuth-Plass algorithm)')}</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{localTheme.gallerySettings?.masonryMode === 'columns'
? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights')
: localTheme.gallerySettings?.masonryMode === 'flickr'
? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm')
: localTheme.gallerySettings?.masonryMode === 'justified'
? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks')
: t('branding.masonryModeHint.rows', 'Custom row-based justified layout')}
</p>
</div>
{/* Row-specific settings - show for all row-based modes */}
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.targetRowHeight', 'Target Row Height')}
</label>
<Input
type="number"
min="150"
max="400"
value={localTheme.gallerySettings?.masonryRowHeight || 250}
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
</p>
</div>
{/* Last row behavior - only for rows and flickr modes */}
{['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.lastRowBehavior', 'Last Row Alignment')}
</label>
<select
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
</select>
</div>
)}
</>
)}
</>
)}
</div>
)}
</Card>
)}
{/* Header Style - Decoupled from Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
{t('branding.headerStyle', 'Header Style')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
<button
key={style}
onClick={() => handleChange('headerStyle', style)}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.headerStyle || 'standard') === style
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
{headerStyleIcons[style]}
</div>
<span className="font-medium text-sm capitalize">
{t(`branding.headerStyleOptions.${style}`, style)}
</span>
<span className="text-xs text-neutral-600 mt-1">
{t(`branding.headerStyleDescriptions.${style}`, '')}
</span>
</div>
{(localTheme.headerStyle || 'standard') === style && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
))}
</div>
{/* Divider Style - Only show when hero header is selected */}
{localTheme.headerStyle === 'hero' && (
<div className="mt-6 pt-6 border-t border-neutral-200">
<h4 className="font-medium text-sm text-neutral-700 mb-3">
{t('branding.heroDividerStyle', 'Divider Style')}
</h4>
<p className="text-xs text-neutral-600 mb-4">
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
<button
key={divider}
onClick={() => handleChange('heroDividerStyle', divider)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(localTheme.heroDividerStyle || 'wave') === divider
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center">
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
<div className="h-8"></div>
{dividerStylePreviews[divider]}
</div>
<span className="text-xs font-medium capitalize">
{t(`branding.dividerOptions.${divider}`, divider)}
</span>
</div>
{(localTheme.heroDividerStyle || 'wave') === divider && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
)}
</button>
))}
</div>
</div>
)}
</Card>
@@ -722,12 +906,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<textarea
value={customCss}
onChange={(e) => {
setCustomCss(e.target.value);
const newCss = e.target.value;
setCustomCss(newCss);
// Mark as custom when CSS is added
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
if (newCss && selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
// Propagate customCss changes to parent in preview mode
if (isPreviewMode) {
onChange({ ...localTheme, customCss: newCss });
}
}}
placeholder="/* Add custom CSS here */"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
@@ -1,12 +1,11 @@
import React from 'react';
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
Image,
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
LayoutGrid,
Layout
} from 'lucide-react';
@@ -25,7 +24,6 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />
};
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
@@ -21,7 +21,6 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />
};
@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowUpCircle, X, ExternalLink } from 'lucide-react';
import { api } from '../../config/api';
interface UpdateInfo {
enabled: boolean;
current: string;
channel: 'stable' | 'beta';
latest: {
stable: string;
beta: string;
forChannel: string;
};
updateAvailable: boolean;
newerBetaAvailable?: boolean;
lastChecked: string;
error?: string;
message?: string;
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
interface UpdateNotificationProps {
onDismiss?: () => void;
}
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false,
refetchOnWindowFocus: false
});
// Don't render if no update available, not enabled, or dismissed
if (!updateInfo?.enabled || !updateInfo?.updateAvailable || dismissed) {
return null;
}
const handleDismiss = () => {
setDismissed(true);
onDismiss?.();
};
const channelLabel = updateInfo.channel === 'beta'
? t('admin.updates.channelBeta', 'Beta')
: t('admin.updates.channelStable', 'Stable');
return (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
<div className="flex items-start justify-between">
<div className="flex items-start">
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h4 className="text-sm font-semibold text-blue-800">
{t('admin.updates.available', 'Update Available')}
</h4>
<p className="text-sm text-blue-700 mt-1">
{t('admin.updates.newVersion', 'Version {{version}} is available', {
version: updateInfo.latest.forChannel
})}
<span className="text-blue-500 ml-2">
({t('admin.updates.currentVersion', 'Current: {{version}}', {
version: updateInfo.current
})})
</span>
</p>
<p className="text-xs text-blue-600 mt-1">
{t('admin.updates.channel', 'Channel: {{channel}}', {
channel: channelLabel
})}
</p>
<a
href="https://github.com/the-luap/picpeak/releases"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mt-2"
>
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
<button
onClick={handleDismiss}
className="text-blue-400 hover:text-blue-600 p-1"
aria-label={t('common.close', 'Close')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
+39 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { Info, ArrowUpCircle } from 'lucide-react';
import { api } from '../../config/api';
import packageJson from '../../../package.json';
@@ -13,6 +13,15 @@ interface SystemVersion {
frontend: string;
node: string;
environment: string;
channel?: 'stable' | 'beta';
}
interface UpdateInfo {
enabled: boolean;
updateAvailable: boolean;
latest?: {
forChannel: string;
};
}
async function fetchSystemVersion(): Promise<SystemVersion> {
@@ -20,6 +29,11 @@ async function fetchSystemVersion(): Promise<SystemVersion> {
return response.data;
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
export const VersionInfo: React.FC = () => {
const { t } = useTranslation();
const { data: versionInfo } = useQuery({
@@ -28,11 +42,25 @@ export const VersionInfo: React.FC = () => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false
});
const channelBadge = versionInfo?.channel === 'beta' ? (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">
{t('admin.updates.beta', 'BETA')}
</span>
) : null;
return (
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
{channelBadge}
</div>
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
<div>Frontend: v{FRONTEND_VERSION}</div>
@@ -40,6 +68,16 @@ export const VersionInfo: React.FC = () => {
<div>Backend: v{versionInfo.backend}</div>
)}
</div>
{updateInfo?.enabled && updateInfo?.updateAvailable && (
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600">
<ArrowUpCircle className="w-3 h-3" />
<span>
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
version: updateInfo.latest?.forChannel
})}
</span>
</div>
)}
</div>
);
};
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
onLoad,
...props
}) => {
const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
onLoad?.();
};
img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas]);
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
if (isLoading) {
return (
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
);
}
return <img src={imageSrc} alt={alt} {...props} />;
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
};
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
@@ -19,6 +21,7 @@ export const DynamicFavicon: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Update favicon when branding settings change
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
@@ -29,13 +32,27 @@ export const DynamicFavicon: React.FC = () => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
// Update document title when company name or tagline changes
useEffect(() => {
const companyName = settings?.branding_company_name?.trim();
const tagline = settings?.branding_company_tagline?.trim();
if (companyName && tagline) {
document.title = `${companyName} - ${tagline}`;
} else if (companyName) {
document.title = companyName;
} else {
document.title = DEFAULT_TITLE;
}
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
};
@@ -8,13 +8,14 @@ import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { buildResourceUrl } from '../../utils/url';
import type { HeaderStyleType } from '../../types/theme.types';
interface GalleryLayoutProps {
event: {
event_name: string;
event_type?: string;
event_date?: string;
expires_at?: string;
event_date?: string | null;
expires_at?: string | null;
};
brandingSettings?: {
company_name?: string;
@@ -56,8 +57,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
// Determine header style - check theme.headerStyle first, then fall back to legacy behavior
const headerStyle: HeaderStyleType = theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid';
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
const headingFontFamily = theme.headingFontFamily || fontFamily;
@@ -123,9 +129,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<DynamicFavicon />
{/* Header structure */}
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts (excluding hero) - keep the current structure */}
{isNonGridLayout && (
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts - keep the current structure */}
{isNonGridLayout && !isHeroHeader && (
<div className="bg-neutral-50 border-b border-neutral-200">
<div className="container py-2">
<div className="flex items-center justify-between">
@@ -170,8 +176,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For grid layout - everything in one bar */}
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
{/* For grid layout - everything in one bar (standard header) */}
{!isNonGridLayout && !isHeroHeader && (
<div className="container py-3">
<div className="flex items-center justify-between gap-2 sm:gap-4">
{/* Left side - Menu button, Logo */}
@@ -240,11 +246,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Right side - Action buttons */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* Extra header items (upload button, etc.) */}
{headerExtra && (
<div className="hidden sm:block">
{headerExtra}
</div>
)}
{headerExtra}
{/* Download all button - hidden on mobile when sidebar is shown */}
{showDownloadAll && onDownloadAll && (
@@ -296,8 +298,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For hero layout - minimal header with just menu and logout */}
{theme.galleryLayout === 'hero' && (
{/* For hero header style - minimal header with just menu and logout */}
{isHeroHeader && (
<div className="container py-3">
<div className="flex items-center justify-between">
{/* Left side - Menu button */}
@@ -341,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
</header>
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
{isNonGridLayout && (
{/* Hero Header for non-grid layouts when using standard header style */}
{isNonGridLayout && !isHeroHeader && (
<div
className="gallery-hero relative text-white overflow-hidden"
style={{
@@ -366,7 +368,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoSize.style || {}),
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandingSettings?.logo_url
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
@@ -441,7 +446,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
<p className="text-xs sm:text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -139,18 +139,18 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Content */}
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
{/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200">
{/* Upload Section - Show prominently at top for mobile users */}
{allowUploads && onUploadClick && (
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => {
onUploadClick();
onClose();
if (isMobile) onClose();
}}
className="w-full"
className="gallery-btn w-full"
>
{t('upload.uploadPhotos')}
</Button>
+23 -31
View File
@@ -30,10 +30,10 @@ interface GalleryViewProps {
id: number;
event_name: string;
event_type: string;
event_date: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expires_at: string | null;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
@@ -203,7 +203,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_size: settingsData.branding_logo_size || 'medium',
@@ -310,10 +310,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
const showUrgentWarning = daysUntilExpiration <= 7;
const isExpired = daysUntilExpiration < 0;
// Calculate days until expiration (null means never expires)
const daysUntilExpiration = event.expires_at
? differenceInDays(parseISO(event.expires_at), new Date())
: null;
const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
// Filter and sort photos
const filteredPhotos = useMemo(() => {
@@ -380,12 +382,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
});
// Transform URLs for watermarks if enabled
// Transform full-size URLs for watermarks if enabled
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/gallery/${slug}/photo/${photo.id}`,
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
@@ -602,15 +604,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerExtra={(() => {
const items = [];
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0 && event.expires_at) {
items.push(
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
);
}
// Upload button only on desktop when sidebar is shown
// Upload button - always show when uploads are allowed (regardless of layout/theme loading state)
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
if (allowUploads && showSidebar && !isMobile) {
if (allowUploads) {
items.push(
<Button
key="upload-button"
@@ -618,22 +620,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
>
{t('upload.uploadPhotos')}
</Button>
);
}
// Upload button for non-sidebar layouts
if (allowUploads && !showSidebar) {
items.push(
<Button
key="upload-button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className="flex-1 sm:flex-initial"
className={!showSidebar ? 'flex-1 sm:flex-initial' : ''}
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
@@ -645,7 +632,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
})()}
>
{/* Expiration Banner */}
{showUrgentWarning && (
{showUrgentWarning && event.expires_at && (
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
@@ -695,7 +682,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
showSelectionControls={!showSidebar}
eventName={event.event_name}
eventLogo={brandingSettings?.logo_url}
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
@@ -704,6 +691,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
/>
</div>
@@ -0,0 +1,62 @@
import React from 'react';
import type { HeroDividerStyle } from '../../types/theme.types';
interface HeroDividerProps {
style: HeroDividerStyle;
fillColor?: string;
className?: string;
}
export const HeroDivider: React.FC<HeroDividerProps> = ({
style,
fillColor = 'var(--color-background, #fafafa)',
className = ''
}) => {
if (style === 'none' || style === 'straight') {
// No visible divider - straight clean edge
return null;
}
switch (style) {
case 'wave':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path
d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
fill={fillColor}
/>
</svg>
</div>
);
case 'angle':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path
d="M0,120 L1200,40 L1200,120 Z"
fill={fillColor}
/>
</svg>
</div>
);
case 'curve':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path
d="M0,80 Q600,0 1200,80 L1200,120 L0,120 Z"
fill={fillColor}
/>
</svg>
</div>
);
default:
return null;
}
};
HeroDivider.displayName = 'HeroDivider';
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ChevronDown, Calendar, Clock } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useTheme } from '../../contexts/ThemeContext';
import { AuthenticatedImage } from '../common';
import { HeroDivider } from './HeroDivider';
import { buildResourceUrl } from '../../utils/url';
import type { Photo } from '../../types';
import type { HeroDividerStyle } from '../../types/theme.types';
interface HeroHeaderProps {
photos: Photo[];
slug: string;
eventName?: string;
eventLogo?: string | null;
eventDate?: string | null;
expiresAt?: string | null;
heroPhotoOverride?: Photo | null;
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
dividerStyle?: HeroDividerStyle;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
onScrollToContent?: () => void;
}
export const HeroHeader: React.FC<HeroHeaderProps> = ({
photos,
slug,
eventName,
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
dividerStyle = 'wave',
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
onScrollToContent
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Helper function to get logo size classes
const getLogoSizeClasses = (size: string): string => {
switch (size) {
case 'small':
return 'h-12 sm:h-14 lg:h-16';
case 'medium':
return 'h-20 sm:h-24 lg:h-32';
case 'large':
return 'h-28 sm:h-32 lg:h-40';
case 'xlarge':
return 'h-36 sm:h-40 lg:h-48';
default:
return 'h-20 sm:h-24 lg:h-32';
}
};
const handleScrollToContent = useCallback(() => {
if (onScrollToContent) {
onScrollToContent();
} else {
// Default: scroll to gallery grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}
}, [onScrollToContent]);
// 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
useEffect(() => {
if (gallerySettings.heroImageId) {
setHasInitialized(false);
}
}, [gallerySettings.heroImageId]);
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// If admin has selected a specific hero image, always use it when available
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null;
return (
<div className="relative -mt-6">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo at top position */}
{heroLogoVisible && heroLogoPosition === 'top' && (
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Title */}
{eventName && (
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
{eventName}
</h1>
)}
{/* Logo at center position (between title and dates) */}
{heroLogoVisible && heroLogoPosition === 'center' && (
<div className="my-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
{/* Logo at bottom position */}
{heroLogoVisible && heroLogoPosition === 'bottom' && (
<div className="mt-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<button
onClick={handleScrollToContent}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</button>
{/* Decorative Divider */}
<HeroDivider style={dividerStyle} />
</div>
</div>
);
};
HeroHeader.displayName = 'HeroHeader';
@@ -49,6 +49,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
},
onError: (error: any) => {
// Revert optimistic update
@@ -5,7 +5,7 @@ import { Button, Input } from '../common';
import type { FilterType } from './GalleryFilter';
interface PhotoCategory {
id: number;
id: number | string;
name: string;
slug: string;
is_global: boolean;
@@ -13,7 +13,7 @@ interface PhotoCategory {
interface Photo {
id: number;
category_id?: number;
category_id?: number | string | null;
like_count?: number;
favorite_count?: number;
}
@@ -21,8 +21,8 @@ interface Photo {
interface PhotoFilterBarProps {
categories?: PhotoCategory[];
photos: Photo[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
selectedCategoryId: number | string | null;
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
@@ -17,14 +17,15 @@ import {
MasonryGalleryLayout,
CarouselGalleryLayout,
TimelineGalleryLayout,
HeroGalleryLayout,
MosaicGalleryLayout,
} from './layouts';
import { HeroHeader } from './HeroHeader';
import type { HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
categoryId?: number | string | null;
// When provided, the hero layout will use this photo
// instead of deriving from the filtered photo list.
heroPhotoOverride?: Photo | null;
@@ -35,8 +36,8 @@ interface PhotoGridWithLayoutsProps {
showSelectionControls?: boolean;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
eventDate?: string | null;
expiresAt?: string | null;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
@@ -52,6 +53,13 @@ interface PhotoGridWithLayoutsProps {
requireNameEmail?: boolean;
};
onFeedbackChange?: () => void;
// Hero logo customization options
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
// Header style (decoupled from layout)
headerStyle?: HeaderStyleType;
heroDividerStyle?: HeroDividerStyle;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -76,7 +84,12 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
eventName,
eventLogo,
eventDate,
expiresAt
expiresAt,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
headerStyle,
heroDividerStyle = 'wave'
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -200,8 +213,15 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
expiresAt,
feedbackEnabled,
feedbackOptions,
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
};
// Determine if we should show hero header (decoupled from layout)
const effectiveHeaderStyle = headerStyle || theme.headerStyle;
const showHeroHeader = effectiveHeaderStyle === 'hero';
let LayoutComponent;
switch (galleryLayout) {
case 'masonry':
@@ -213,9 +233,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
case 'timeline':
LayoutComponent = TimelineGalleryLayout;
break;
case 'hero':
LayoutComponent = HeroGalleryLayout;
break;
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
@@ -225,6 +242,27 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
return (
<>
{/* Hero Header - shown when headerStyle is 'hero' */}
{showHeroHeader && (
<HeroHeader
photos={photos}
slug={slug}
eventName={eventName}
eventLogo={eventLogo}
eventDate={eventDate}
expiresAt={expiresAt}
heroPhotoOverride={heroPhotoOverride}
heroLogoVisible={heroLogoVisible}
heroLogoSize={heroLogoSize}
heroLogoPosition={heroLogoPosition}
dividerStyle={heroDividerStyle}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
)}
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
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);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Reset image loaded state when changing photos
useEffect(() => {
setImageLoaded(false);
}, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
opacity: imageLoaded ? 1 : 0,
}}
draggable={false}
onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
@@ -49,6 +49,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
},
onError: (error: any) => {
// Revert optimistic update
@@ -15,8 +15,8 @@ export interface BaseGalleryLayoutProps {
onPhotoSelect?: (photoId: number) => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
eventDate?: string | null;
expiresAt?: string | null;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
@@ -1,6 +1,7 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
@@ -59,6 +60,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
liked = false,
onLikeSuccess
}) => {
const { t } = useTranslation();
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
@@ -1,347 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
// Use a static hero photo independent of current filter
heroPhotoOverride?: Photo | null;
}
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
eventName,
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
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 overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
const gridRef = useRef<HTMLDivElement | null>(null);
const handleScrollToGrid = useCallback(() => {
if (gridRef.current) {
gridRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
// 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
useEffect(() => {
if (gallerySettings.heroImageId) {
setHasInitialized(false);
}
}, [gallerySettings.heroImageId]);
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// If admin has selected a specific hero image, always use it when available
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null;
// Show all photos including the hero photo in the grid
const remainingPhotos = photos;
return (
<>
<div className="relative -mt-6">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo - Show custom logo or fallback to PicPeak logo */}
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
{/* Event Title */}
{eventName && (
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
{eventName}
</h1>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<button
onClick={() => {
// Scroll to the grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</button>
</div>
{/* Grid Section */}
<div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => onPhotoClick(actualIndex)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
slug={slug}
photoId={photo.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onPhotoClick(actualIndex);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</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>
{/* 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>
{/* 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>
<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"
/>
</>
);
};
@@ -0,0 +1,785 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, ChevronDown, Calendar, Clock } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { buildResourceUrl } from '../../../utils/url';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import {
calculateJustifiedLayout,
createJustifiedPhotos,
type JustifiedLayoutItem,
} from '../../../utils/justifiedLayoutCalculator';
interface JustifiedGalleryLayoutProps extends BaseGalleryLayoutProps {
// Hero section props (optional)
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
heroPhotoOverride?: Photo | null;
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
}
interface JustifiedPhotoProps {
photo: Photo;
layoutItem: JustifiedLayoutItem;
isSelected: boolean;
isSelectionMode: boolean;
onClick: () => void;
onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
animationType?: string;
allowDownloads?: boolean;
slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: 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;
liked?: boolean;
onLikeSuccess?: () => void;
}
const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
photo,
layoutItem,
isSelected,
isSelectionMode,
onClick,
onDownload,
onToggleSelect,
animationType = 'fade',
allowDownloads = true,
slug,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
savedIdentity,
onRequireIdentity,
onQuickComment,
onFeedbackChange,
liked = false,
onLikeSuccess,
}) => {
const [overlayVisible, setOverlayVisible] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const overlayTimeoutRef = useRef<number | null>(null);
// Detect touch device
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)');
const updateTouchState = () => {
const hasNavigator = typeof navigator !== 'undefined';
setIsTouchDevice(
mediaQuery.matches ||
'ontouchstart' in window ||
(hasNavigator && navigator.maxTouchPoints > 0)
);
};
updateTouchState();
const listener = (event: MediaQueryListEvent) => {
setIsTouchDevice(event.matches);
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', listener);
} else if (mediaQuery.addListener) {
mediaQuery.addListener(listener);
}
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', listener);
} else if (mediaQuery.removeListener) {
mediaQuery.removeListener(listener);
}
};
}, []);
const hideOverlay = useCallback(() => {
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
overlayTimeoutRef.current = null;
setOverlayVisible(false);
}, []);
const showOverlayTemporarily = useCallback(() => {
setOverlayVisible(true);
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
if (typeof window !== 'undefined') {
overlayTimeoutRef.current = window.setTimeout(() => {
overlayTimeoutRef.current = null;
setOverlayVisible(false);
}, 2500);
}
}, []);
useEffect(() => {
return () => {
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (isSelectionMode) {
hideOverlay();
}
}, [isSelectionMode, hideOverlay]);
// Lazy loading with intersection observer
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
rootMargin: '100px',
});
const animationClass =
animationType === 'scale'
? 'transition-transform duration-300 hover:scale-[1.02]'
: animationType === 'fade'
? '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);
const overlayVisibilityClass = overlayVisible
? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0';
const checkboxVisibilityClass =
isSelected || isSelectionMode || overlayVisible
? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0';
const isVideo =
photo.media_type === 'video' ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
e.preventDefault();
e.stopPropagation();
showOverlayTemporarily();
return;
}
onClick();
if (isTouchDevice) {
hideOverlay();
}
};
return (
<div
ref={ref}
className={`photo-card absolute group cursor-pointer overflow-hidden rounded-lg ${animationClass}`}
style={{
top: layoutItem.y,
left: layoutItem.x,
width: layoutItem.width,
height: layoutItem.height,
opacity: !inView && animationType === 'fade' ? 0 : 1,
}}
onClick={handlePhotoClick}
role="button"
tabIndex={0}
aria-label={`View photo ${photo.filename}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{inView ? (
<>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'maximum'}
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
onProtectionViolation={(violationType: string) => {
console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`);
}}
/>
{/* Hover Overlay */}
<div
className={`absolute inset-0 bg-black/40 transition-opacity duration-200 flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}
>
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick();
hideOverlay();
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onDownload(e);
hideOverlay();
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onQuickComment();
hideOverlay();
}}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
{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);
hideOverlay();
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();
hideOverlay();
}}
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>
{/* Selection Checkbox */}
<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 ${checkboxVisibilityClass} md: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>
{/* Feedback Indicators */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && (
<div
className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}
>
{(likeCount > 0 || liked) && (
<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>
)}
{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>
)}
{/* Video Badge */}
{isVideo && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Video className="w-3 h-3" />
Video
</span>
</div>
)}
{/* Collage Badge */}
{photo.type === 'collage' && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
</div>
)}
</>
) : (
<div className="skeleton w-full h-full rounded-lg" />
)}
</div>
);
};
export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onFeedbackChange,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
// Hero props
eventName,
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasHeroInitialized, setHasHeroInitialized] = useState(false);
const gallerySettings = theme.gallerySettings || {};
const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade';
const targetRowHeight = gallerySettings.justifiedRowHeight || 250;
const lastRowBehavior = gallerySettings.justifiedLastRowBehavior || 'left';
const showHero = gallerySettings.justifiedShowHero || false;
const heroHeight = gallerySettings.justifiedHeroHeight || 'medium';
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Helper function to get hero height classes
const getHeroHeightClass = (height: string): string => {
switch (height) {
case 'small':
return 'h-[40vh] sm:h-[50vh]';
case 'medium':
return 'h-[50vh] sm:h-[60vh] lg:h-[70vh]';
case 'large':
return 'h-[60vh] sm:h-[70vh] lg:h-[80vh]';
default:
return 'h-[50vh] sm:h-[60vh] lg:h-[70vh]';
}
};
// Helper function to get logo size classes
const getLogoSizeClasses = (size: string): string => {
switch (size) {
case 'small':
return 'h-12 sm:h-14 lg:h-16';
case 'medium':
return 'h-20 sm:h-24 lg:h-32';
case 'large':
return 'h-28 sm:h-32 lg:h-40';
case 'xlarge':
return 'h-36 sm:h-40 lg:h-48';
default:
return 'h-20 sm:h-24 lg:h-32';
}
};
// Initialize hero photo when hero is enabled
useEffect(() => {
if (!showHero) return;
if (heroPhotoOverride) {
setHeroPhoto(heroPhotoOverride);
setHasHeroInitialized(true);
return;
}
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasHeroInitialized(true);
return;
}
}
if (!hasHeroInitialized) {
setHeroPhoto(photos[0]);
setHasHeroInitialized(true);
}
}
}, [showHero, photos, gallerySettings.heroImageId, hasHeroInitialized, heroPhotoOverride]);
// Get spacing value in pixels
const spacingPixels = spacing === 'tight' ? 8 : spacing === 'relaxed' ? 24 : 16;
// Identity modal state
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(
null
);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Track container width with ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () => {
const width = container.offsetWidth;
if (width > 0) {
setContainerWidth(width);
}
};
// Initial measurement
updateWidth();
// Use ResizeObserver for responsive updates
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
setContainerWidth(entry.contentRect.width);
}
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
// Calculate layout whenever photos or container width changes
const layoutResult = useMemo(() => {
if (containerWidth <= 0 || photos.length === 0) {
return { items: [], containerHeight: 0, rowCount: 0 };
}
// Convert photos to justified format
const justifiedPhotos = createJustifiedPhotos(
photos.map((p) => ({
id: p.id,
width: p.width,
height: p.height,
}))
);
return calculateJustifiedLayout(justifiedPhotos, {
containerWidth,
targetRowHeight,
spacing: spacingPixels,
lastRowBehavior,
});
}, [photos, containerWidth, targetRowHeight, spacingPixels, lastRowBehavior]);
// Create a map for quick lookup of layout items by photo ID
const layoutItemMap = useMemo(() => {
const map = new Map<number, JustifiedLayoutItem>();
for (const item of layoutResult.items) {
map.set(item.photoId, item);
}
return map;
}, [layoutResult.items]);
return (
<>
{/* Hero Section (optional) */}
{showHero && heroPhoto && (
<div className="relative -mt-6 mb-8">
<div className={`relative ${getHeroHeightClass(heroHeight)} -mx-4 sm:-mx-6 lg:-mx-8`}>
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo at top position */}
{heroLogoVisible && heroLogoPosition === 'top' && eventLogo && (
<div className="mb-6">
<img
src={buildResourceUrl(eventLogo)}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Title */}
{eventName && (
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
{eventName}
</h1>
)}
{/* Logo at center position */}
{heroLogoVisible && heroLogoPosition === 'center' && eventLogo && (
<div className="my-6">
<img
src={buildResourceUrl(eventLogo)}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
{/* Logo at bottom position */}
{heroLogoVisible && heroLogoPosition === 'bottom' && eventLogo && (
<div className="mt-6">
<img
src={buildResourceUrl(eventLogo)}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<button
onClick={() => {
const gridSection = document.getElementById('justified-gallery-grid');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollBy({ top: window.innerHeight * 0.7, behavior: 'smooth' });
}
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</button>
</div>
</div>
)}
{/* Justified Photo Grid */}
<div
id="justified-gallery-grid"
ref={containerRef}
className="photo-grid relative"
style={{ height: layoutResult.containerHeight }}
>
{photos.map((photo, index) => {
const layoutItem = layoutItemMap.get(photo.id);
if (!layoutItem) return null;
return (
<JustifiedPhoto
key={photo.id}
photo={photo}
layoutItem={layoutItem}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
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,
});
if (pendingAction.type === 'like') {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(pendingAction.photoId);
return next;
});
}
setPendingAction(null);
}
}}
feedbackType="like"
/>
</div>
</>
);
};
@@ -1,9 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import {
calculateJustifiedLayout,
createJustifiedPhotos,
type JustifiedLayoutItem,
} from '../../../utils/justifiedLayoutCalculator';
// Flickr's justified-layout library
import justifiedLayout from 'justified-layout';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -24,6 +31,8 @@ interface MasonryPhotoProps {
requireNameEmail?: boolean;
};
onQuickComment?: () => void;
// Column width for calculating proper aspect-ratio-based height
columnWidth?: number;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -38,19 +47,28 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
feedbackEnabled = false,
slug,
feedbackOptions,
onQuickComment
onQuickComment,
columnWidth = 300
}) => {
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
useEffect(() => {
const heights = [200, 250, 300, 350, 400];
const randomHeight = heights[Math.floor(Math.random() * heights.length)];
setImageHeight(randomHeight);
}, [photo.id]);
// Calculate height based on actual photo aspect ratio
// This preserves the photo's natural proportions in the masonry layout
const imageHeight = useMemo(() => {
const photoWidth = photo.width || 800;
const photoHeight = photo.height || 600;
const aspectRatio = photoWidth / photoHeight;
// Calculate height based on column width and aspect ratio
// Clamp to reasonable min/max heights for visual consistency
const calculatedHeight = columnWidth / aspectRatio;
const minHeight = 150;
const maxHeight = 500;
return Math.max(minHeight, Math.min(maxHeight, calculatedHeight));
}, [photo.width, photo.height, columnWidth]);
return (
<div
@@ -216,14 +234,20 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
const [containerWidth, setContainerWidth] = useState(0);
const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16;
const mode = gallerySettings.masonryMode || 'columns';
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
// Calculate number of columns based on container width
// Calculate number of columns based on container width (for columns mode)
useEffect(() => {
const updateColumns = () => {
const updateDimensions = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setContainerWidth(width);
if (width < 640) setColumns(2);
else if (width < 1024) setColumns(3);
else if (width < 1280) setColumns(4);
@@ -231,17 +255,497 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
updateDimensions();
// Use ResizeObserver for better performance
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
setContainerWidth(entry.contentRect.width);
const width = entry.contentRect.width;
if (width < 640) setColumns(2);
else if (width < 1024) setColumns(3);
else if (width < 1280) setColumns(4);
else setColumns(5);
}
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
// Distribute photos across columns
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
photos.forEach((photo, index) => {
photoColumns[index % columns].push(photo);
});
// Calculate justified layout for rows mode
const rowsLayout = useMemo(() => {
if (mode !== 'rows' || containerWidth <= 0 || photos.length === 0) {
return { items: [], containerHeight: 0, rowCount: 0 };
}
const justifiedPhotos = createJustifiedPhotos(
photos.map((p) => ({
id: p.id,
width: p.width,
height: p.height,
}))
);
return calculateJustifiedLayout(justifiedPhotos, {
containerWidth,
targetRowHeight,
spacing: gutter,
lastRowBehavior,
});
}, [mode, photos, containerWidth, targetRowHeight, gutter, lastRowBehavior]);
// Create a map for quick lookup of layout items by photo ID (rows mode)
const layoutItemMap = useMemo(() => {
const map = new Map<number, JustifiedLayoutItem>();
for (const item of rowsLayout.items) {
map.set(item.photoId, item);
}
return map;
}, [rowsLayout.items]);
// Calculate Flickr justified layout
const flickrLayout = useMemo(() => {
if (mode !== 'flickr' || containerWidth <= 0 || photos.length === 0) {
return { containerHeight: 0, boxes: [] };
}
// Convert photos to aspect ratios array
const aspectRatios = photos.map((p) => {
if (p.width && p.height && p.width > 0 && p.height > 0) {
return p.width / p.height;
}
return 1; // Default to square if no dimensions
});
const result = justifiedLayout(aspectRatios, {
containerWidth,
targetRowHeight,
boxSpacing: gutter,
containerPadding: 0,
targetRowHeightTolerance: 0.25,
});
return result;
}, [mode, photos, containerWidth, targetRowHeight, gutter]);
// Distribute photos across columns using greedy "shortest column" algorithm
// This creates a more balanced masonry layout instead of round-robin
const photoColumns: Photo[][] = useMemo(() => {
if (mode !== 'columns' || photos.length === 0) {
return Array.from({ length: columns }, () => []);
}
const cols: Photo[][] = Array.from({ length: columns }, () => []);
const colHeights: number[] = Array(columns).fill(0);
// Calculate approximate column width for height estimation
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
photos.forEach((photo) => {
// Find the shortest column
let shortestCol = 0;
let minHeight = colHeights[0];
for (let i = 1; i < columns; i++) {
if (colHeights[i] < minHeight) {
minHeight = colHeights[i];
shortestCol = i;
}
}
// Add photo to shortest column
cols[shortestCol].push(photo);
// Estimate height based on aspect ratio
const photoWidth = photo.width || 800;
const photoHeight = photo.height || 600;
const aspectRatio = photoWidth / photoHeight;
const estimatedHeight = Math.max(150, Math.min(500, approxColWidth / aspectRatio));
colHeights[shortestCol] += estimatedHeight + gutter;
});
return cols;
}, [mode, photos, columns, containerWidth, gutter]);
// Calculate approximate column width for aspect ratio calculations
const columnWidth = useMemo(() => {
if (containerWidth <= 0 || columns <= 0) return 300;
// Account for gaps between columns
const totalGaps = (columns - 1) * gutter;
return (containerWidth - totalGaps) / columns;
}, [containerWidth, columns, gutter]);
// ROWS MODE - Google Photos style justified layout
if (mode === 'rows') {
// Show loading state while measuring container width
const isCalculating = containerWidth <= 0 || rowsLayout.items.length === 0;
return (
<div
ref={containerRef}
className="photo-grid relative"
style={{
height: isCalculating ? 'auto' : rowsLayout.containerHeight,
minHeight: isCalculating ? 200 : undefined
}}
>
{isCalculating ? (
// Render a simple grid while calculating to get container width
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.slice(0, 8).map((photo) => (
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
))}
</div>
) : photos.map((photo, index) => {
const layoutItem = layoutItemMap.get(photo.id);
if (!layoutItem) return null;
return (
<div
key={photo.id}
className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10"
style={{
left: layoutItem.x,
top: layoutItem.y,
width: layoutItem.width,
height: layoutItem.height,
}}
onClick={() => onPhotoClick(index)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
{/* Feedback Indicators */}
{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">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
{(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-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 ?? 0).toFixed(1)}</span>
</div>
)}
{(photo.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
</div>
)}
{/* Hover overlay with actions */}
<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">
{!isSelectionMode && (
<>
<button
type="button"
aria-label="View full size"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
>
<Maximize2 className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
type="button"
aria-label="Download photo"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
>
<Download className="w-5 h-5 text-white" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(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>
);
}
// FLICKR MODE - Flickr's justified-layout algorithm
if (mode === 'flickr') {
const isCalculating = containerWidth <= 0 || flickrLayout.boxes.length === 0;
return (
<div
ref={containerRef}
className="photo-grid relative"
style={{
height: isCalculating ? 'auto' : flickrLayout.containerHeight,
minHeight: isCalculating ? 200 : undefined
}}
>
{isCalculating ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.slice(0, 8).map((photo) => (
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
))}
</div>
) : photos.map((photo, index) => {
const box = flickrLayout.boxes[index];
if (!box) return null;
return (
<div
key={photo.id}
className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
}}
onClick={() => onPhotoClick(index)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
{/* Feedback Indicators */}
{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">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
{(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-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 ?? 0).toFixed(1)}</span>
</div>
)}
{(photo.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
</div>
)}
{/* Hover overlay with actions */}
<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">
{!isSelectionMode && (
<>
<button
type="button"
aria-label="View full size"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
>
<Maximize2 className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
type="button"
aria-label="Download photo"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
>
<Download className="w-5 h-5 text-white" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(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>
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
</div>
)}
</div>
);
})}
</div>
);
}
// QUILTED MODE - Mixed sizes based on aspect ratio
// Landscape photos span 2 columns, portrait photos span 2 rows
if (mode === 'quilted') {
// Determine grid span based on aspect ratio
const getSpanClasses = (photo: Photo): string => {
const width = photo.width || 800;
const height = photo.height || 600;
const ratio = width / height;
// Very wide landscape (panoramic) - span 2 columns
if (ratio > 1.5) return 'col-span-2';
// Very tall portrait - span 2 rows
if (ratio < 0.7) return 'row-span-2';
// Normal aspect ratio - single cell
return '';
};
return (
<div
ref={containerRef}
className="photo-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gridAutoRows: '200px',
gap: `${gutter}px`,
gridAutoFlow: 'dense', // Fill gaps automatically
}}
>
{photos.map((photo, index) => {
const spanClasses = getSpanClasses(photo);
return (
<div
key={photo.id}
className={`photo-card group cursor-pointer relative overflow-hidden rounded-lg bg-neutral-100 ${spanClasses}`}
onClick={() => onPhotoClick(index)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
{/* Feedback Indicators */}
{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">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
{(photo.average_rating ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-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 ?? 0).toFixed(1)}</span>
</div>
)}
{(photo.like_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<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 ?? 0}</span>
</div>
)}
</div>
)}
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
type="button"
aria-label="View full size"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
>
<Maximize2 className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
type="button"
aria-label="Download photo"
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
>
<Download className="w-5 h-5 text-white" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(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>
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">Collage</span>
</div>
)}
</div>
);
})}
</div>
);
}
// COLUMNS MODE - Pinterest style masonry (default)
return (
<div
ref={containerRef}
@@ -249,8 +753,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
style={{ gap: `${gutter}px` }}
>
{photoColumns.map((column, columnIndex) => (
<div
key={columnIndex}
<div
key={columnIndex}
className="flex-1 flex flex-col"
style={{ gap: `${gutter}px` }}
>
@@ -270,6 +774,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
columnWidth={columnWidth}
/>
);
})}
@@ -1,12 +1,23 @@
import React from 'react';
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
// import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
/**
* Mosaic Gallery Layout
*
* Uses CSS Columns for a gap-free masonry/mosaic effect.
* Images flow vertically within columns, maintaining their natural aspect ratios.
* This approach eliminates gaps that occur with CSS Grid span rules.
*
* Based on:
* - https://css-tricks.com/seamless-responsive-photo-grid/
* - https://www.30secondsofcode.org/css/s/image-mosaic/
*/
interface MosaicPhotoProps {
photo: Photo;
isSelected: boolean;
@@ -14,7 +25,6 @@ interface MosaicPhotoProps {
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
className?: string;
allowDownloads?: boolean;
slug?: string;
feedbackEnabled?: boolean;
@@ -33,7 +43,6 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
onClick,
onDownload,
onToggleSelect,
className = '',
allowDownloads = true,
slug,
feedbackEnabled = false,
@@ -46,139 +55,144 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
// Calculate aspect ratio from photo dimensions (fallback to 1 if unknown)
const aspectRatio = (photo.width && photo.height) ? photo.width / photo.height : 1;
return (
<>
<div
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
<div className="absolute inset-0">
<div
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 mb-2"
style={{
breakInside: 'avoid',
aspectRatio: aspectRatio.toString()
}}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</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) => {
onClick={(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 (_) {}
onClick(e);
}}
aria-label="Like photo"
aria-pressed={likedLocal}
title="Like"
aria-label="View full size"
>
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
<Maximize2 className="w-5 h-5 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>
)}
</>
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</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>
{/* Feedback Indicators (bottom-left) */}
{((photo.like_count ?? 0) > 0 || likedLocal) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<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>
</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' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</div>
{/* Feedback Indicators (bottom-left) */}
{((photo.like_count ?? 0) > 0 || likedLocal) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
<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>
</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' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</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);
}
}}
<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"
/>
/>
</>
);
};
@@ -196,229 +210,51 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions
}) => {
// const { theme } = useTheme();
// const gallerySettings = theme.gallerySettings || {};
// const pattern = gallerySettings.mosaicPattern || 'structured';
const handlePhotoClick = (index: number, photoId: number) => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photoId);
} else {
onPhotoClick(index);
}
};
// Create a more structured mosaic layout
const renderMosaicLayout = () => {
const elements = [];
let photoIndex = 0;
let patternIndex = 0;
while (photoIndex < photos.length) {
const remainingPhotos = photos.length - photoIndex;
// Choose pattern based on rotation and remaining photos
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
// Pattern 1: Large left, 2 small right
// Capture indices immediately to avoid closure issues
const idx0 = photoIndex;
const idx1 = photoIndex + 1;
const idx2 = photoIndex + 2;
const photo0 = photos[idx0];
const photo1 = photos[idx1];
const photo2 = photos[idx2];
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
{photo0 && (
<MosaicPhoto
photo={photo0}
isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-1"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/>
)}
<div className="grid grid-rows-2 gap-2">
{photo1 && (
<MosaicPhoto
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)}
{photo2 && (
<MosaicPhoto
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)}
</div>
</div>
);
photoIndex += 3;
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
// Pattern 2: 3 equal columns
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
{[0, 1, 2].map(offset => {
const currentIndex = photoIndex + offset;
const photo = photos[currentIndex];
return photo ? (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(currentIndex, photo.id)}
onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(currentIndex); }}
/>
) : null;
})}
</div>
);
photoIndex += 3;
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
// Pattern 3: Large span-2 with 2 small on right
// Capture indices immediately to avoid closure issues
const idx0 = photoIndex;
const idx1 = photoIndex + 1;
const idx2 = photoIndex + 2;
const photo0 = photos[idx0];
const photo1 = photos[idx1];
const photo2 = photos[idx2];
elements.push(
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
{photo0 && (
<MosaicPhoto
photo={photo0}
isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-2"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
/>
)}
<div className="grid grid-rows-2 gap-2">
{photo1 && (
<MosaicPhoto
photo={photo1}
isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
/>
)}
{photo2 && (
<MosaicPhoto
photo={photo2}
isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
className=""
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
/>
)}
</div>
</div>
);
photoIndex += 3;
} else {
// Handle remaining photos that don't fit patterns
break;
}
patternIndex++;
}
// Add remaining photos in a regular grid
if (photoIndex < photos.length) {
const remainingPhotos = photos.slice(photoIndex);
elements.push(
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{remainingPhotos.map((photo, idx) => {
const index = photoIndex + idx;
return (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className="aspect-square"
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(index); }}
/>
);
})}
</div>
);
}
return elements;
};
return (
<div className="photo-grid w-full max-w-7xl mx-auto">
{renderMosaicLayout()}
<div
className="photo-grid w-full"
style={{
columnCount: 4,
columnGap: '8px',
}}
>
<style>{`
@media (max-width: 1280px) {
.photo-grid { column-count: 3 !important; }
}
@media (max-width: 1024px) {
.photo-grid { column-count: 2 !important; }
}
@media (max-width: 640px) {
.photo-grid { column-count: 1 !important; }
}
`}</style>
{photos.map((photo, index) => (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
allowDownloads={allowDownloads}
slug={slug}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
onQuickComment={() => {
if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) {
onOpenPhotoWithFeedback(index);
}
}}
/>
))}
</div>
);
};
@@ -2,6 +2,7 @@ export { GridGalleryLayout } from './GridGalleryLayout';
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
export { HeroGalleryLayout } from './HeroGalleryLayout';
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
// Note: HeroGalleryLayout has been deprecated in favor of HeroHeader component
// which can be used with any layout via the headerStyle setting
+15 -12
View File
@@ -16,10 +16,10 @@ interface GalleryEvent {
id: number;
event_name: string;
event_type: string;
event_date: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expires_at: string | null;
require_password?: boolean;
}
@@ -218,16 +218,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
if (response?.event) {
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
// Store token and slug BEFORE setting authenticated state to avoid
// race condition where photo queries fire before token is available
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
setIsAuthenticated(true);
return;
}
}
@@ -263,17 +265,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true);
// Store token and slug BEFORE setting authenticated state to avoid
// race condition where photo queries fire before token is available
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
@@ -16,7 +16,6 @@ export interface GeneralSettings {
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
enable_watermark: boolean;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
@@ -49,6 +48,8 @@ export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
}
export function useSettingsState() {
@@ -74,7 +75,6 @@ export function useSettingsState() {
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
@@ -109,7 +109,9 @@ export function useSettingsState() {
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
});
// Account form state
@@ -142,7 +144,6 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
@@ -178,7 +179,9 @@ export function useSettingsState() {
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
});
}
}, [settings, i18n]);
@@ -99,6 +99,56 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_event_date}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_event_date: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireEventDate', 'Require event date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
</p>
{!eventSettings.event_require_event_date && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.eventDateWarning', 'Gallery URLs will use random identifiers instead of dates')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_expiration}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_expiration: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireExpiration', 'Require expiration date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
</p>
{!eventSettings.event_require_expiration && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.expirationWarning', 'Galleries without expiration will remain active until manually archived')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
@@ -184,16 +184,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_watermark}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
+2 -2
View File
@@ -27,8 +27,8 @@ i18n
escapeValue: false,
},
// Use v3 format for pluralization (_plural suffix instead of _one/_other)
compatibilityJSON: 'v3',
// Use v4 format for pluralization (_one/_other instead of _plural suffix)
compatibilityJSON: 'v4',
detection: {
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
+227 -4
View File
@@ -154,6 +154,7 @@
"events": "Veranstaltungen",
"archives": "Archive",
"settings": "Einstellungen",
"eventTypes": "Veranstaltungsarten",
"branding": "Branding",
"analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen",
@@ -161,6 +162,54 @@
"cmsPages": "CMS-Seiten",
"users": "Benutzer"
},
"eventTypes": {
"title": "Veranstaltungsarten",
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
"loading": "Lade Veranstaltungsarten...",
"loadError": "Fehler beim Laden der Veranstaltungsarten",
"createNew": "Neue Veranstaltungsart",
"edit": "Veranstaltungsart bearbeiten",
"created": "Veranstaltungsart erfolgreich erstellt",
"updated": "Veranstaltungsart erfolgreich aktualisiert",
"deleted": "Veranstaltungsart erfolgreich gelöscht",
"createError": "Fehler beim Erstellen der Veranstaltungsart",
"updateError": "Fehler beim Aktualisieren der Veranstaltungsart",
"deleteError": "Fehler beim Löschen der Veranstaltungsart",
"searchPlaceholder": "Veranstaltungsarten suchen...",
"showInactive": "Inaktive anzeigen",
"noResults": "Keine Veranstaltungsarten gefunden",
"empty": "Noch keine Veranstaltungsarten",
"system": "System",
"table": {
"type": "Typ",
"slugPrefix": "URL-Präfix",
"theme": "Standard-Theme",
"status": "Status",
"actions": "Aktionen"
},
"form": {
"name": "Anzeigename",
"namePlaceholder": "z.B. Familienshooting",
"slugPrefix": "URL-Präfix",
"slugPrefixPlaceholder": "z.B. familie",
"slugPreview": "Beispiel-URL:",
"emoji": "Symbol",
"themePreset": "Standard-Theme",
"isActive": "Aktiv (sichtbar bei Veranstaltungserstellung)"
},
"validation": {
"slugFormat": "Nur Buchstaben, Zahlen und Bindestriche erlaubt"
},
"slugInfo": {
"title": "URL-Präfix Info:",
"description": "Das URL-Präfix wird zur Generierung von Galerie-URLs verwendet. Zum Beispiel erstellt eine Veranstaltungsart mit Präfix \"familie\" URLs wie: familie-mueller-familie-2025-01-22"
},
"deleteConfirm": {
"title": "Veranstaltungsart löschen",
"message": "Sind Sie sicher, dass Sie löschen möchten",
"warning": "Diese Aktion kann nicht rückgängig gemacht werden. Stellen Sie sicher, dass keine Veranstaltungen diesen Typ verwenden."
}
},
"backup": {
"external": {
"warning": {
@@ -759,6 +808,8 @@
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
@@ -778,6 +829,35 @@
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
"protectionLevelBasic": "Einfach - Nur Rechtsklick-Sperre",
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
"heroLogoSettings": "Hero-Logo-Einstellungen",
"heroLogoVisible": "Logo im Hero-Bereich anzeigen",
"heroLogoSize": "Logo-Größe",
"heroLogoSizeSmall": "Klein",
"heroLogoSizeMedium": "Mittel",
"heroLogoSizeLarge": "Groß",
"heroLogoSizeXLarge": "Extra Groß",
"heroLogoPosition": "Logo-Position",
"heroLogoPositionTop": "Oben (über dem Titel)",
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
"heroLogoPositionBottom": "Unten (unter dem Datum)",
"heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.",
"eventCustomLogo": "Individuelles Event-Logo",
"eventCustomLogoDescription": "Laden Sie ein individuelles Logo für dieses Event hoch. Dies überschreibt das globale Branding-Logo nur für diese Galerie.",
"uploadEventLogo": "Logo hochladen",
"replaceLogo": "Ersetzen",
"removeLogo": "Entfernen",
"eventLogoUploaded": "Event-Logo erfolgreich hochgeladen",
"eventLogoUploadFailed": "Event-Logo konnte nicht hochgeladen werden",
"eventLogoRemoved": "Event-Logo erfolgreich entfernt",
"eventLogoRemoveFailed": "Event-Logo konnte nicht entfernt werden",
"heroLogoVisibleLabel": "Logo sichtbar",
"heroLogoSizeLabel": "Größe",
"heroLogoPositionLabel": "Position",
"heroLogoHidden": "Logo ausgeblendet",
"heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen",
@@ -904,7 +984,6 @@
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
@@ -1032,6 +1111,54 @@
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"events": {
"title": "Veranstaltungserstellung",
"requiredFields": "Pflichtfelder",
"requiredFieldsDescription": "Konfigurieren Sie, welche Kontaktfelder beim Erstellen neuer Veranstaltungen erforderlich sind.",
"requireCustomerName": "Kundenname erforderlich",
"requireCustomerNameHelp": "Kundenname muss für neue Veranstaltungen angegeben werden",
"requireCustomerEmail": "Kunden-E-Mail erforderlich",
"requireCustomerEmailHelp": "Kunden-E-Mail muss für neue Veranstaltungen angegeben werden",
"customerEmailWarning": "Erforderlich für das Versenden von Galerie-Einladungen",
"requireAdminEmail": "Admin-E-Mail erforderlich",
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
"requireEventDate": "Veranstaltungsdatum erforderlich",
"requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden",
"eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten",
"requireExpiration": "Ablaufdatum erforderlich",
"requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben",
"expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden",
"saveSettings": "Veranstaltungseinstellungen speichern",
"noteTitle": "Hinweis",
"noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
},
"imageSecurity": {
"title": "Bildschutz",
"saveSuccess": "Bildschutz-Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"loadError": "Fehler beim Laden der Bildschutz-Einstellungen",
"defaultProtection": "Standard-Schutzeinstellungen",
"defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
"protectionLevel": "Standard-Schutzstufe",
"imageQuality": "Standard-Bildqualität",
"fragmentationLevel": "Fragmentierungsstufe",
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
"enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
"rateLimiting": "Ratenbegrenzung",
"rateLimitingHelp": "Begrenzen Sie, wie viele Bilder angefordert werden können, um Scraping zu verhindern.",
"requestsPerMinute": "Anfragen pro Minute",
"requestsPer5Minutes": "Anfragen pro 5 Min.",
"requestsPerHour": "Anfragen pro Stunde",
"securityMonitoring": "Sicherheitsüberwachung",
"suspiciousThreshold": "Schwellenwert für verdächtige Aktivitäten",
"autoBlockThreshold": "Automatische Sperrschwelle",
"enableMonitoring": "Sicherheitsüberwachung aktivieren",
"blockSuspiciousIps": "Verdächtige IPs automatisch blockieren",
"logEvents": "Sicherheitsereignisse in Datenbank protokollieren",
"infoTitle": "Über Bildschutz",
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
},
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
@@ -1092,7 +1219,6 @@
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
"watermark": "Wasserzeichen",
"watermarkSettings": "Wasserzeichen-Einstellungen",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableWatermarks": "Wasserzeichen aktivieren",
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
"watermarkLogo": "Wasserzeichen-Logo",
@@ -1132,7 +1258,8 @@
"carousel": "Vollbild-Diashow mit Navigation",
"timeline": "Nach Datum organisierte Fotos",
"hero": "Hervorgehobenes Bild mit Raster darunter",
"mosaic": "Künstlerisches Layout mit gemischten Größen"
"mosaic": "Künstlerisches Layout mit gemischten Größen",
"justified": "Zeilenbasiertes Layout mit Seitenverhältnis-Erhaltung"
},
"layoutSettings": "Layout-Einstellungen",
"photoSpacing": "Foto-Abstand",
@@ -1160,6 +1287,37 @@
"week": "Woche",
"month": "Monat"
},
"masonryMode": "Layout-Modus",
"masonryModeOptions": {
"columns": "Spalten (Pinterest-Stil)",
"rows": "Zeilen (Eigenes Layout)",
"flickr": "Flickr (Bewährtes Layout)",
"justified": "Google Fotos (Knuth-Plass Algorithmus)"
},
"masonryModeHint": {
"columns": "Pinterest-ähnliche vertikale Spalten mit variierenden Höhen",
"rows": "Eigenes zeilenbasiertes Layout",
"flickr": "Flickrs Open-Source Justified-Layout Algorithmus",
"justified": "Google Fotos-ähnliche Zeilen mit Knuth-Plass Algorithmus für optimale Umbrüche"
},
"targetRowHeight": "Ziel-Zeilenhöhe",
"targetRowHeightHint": "Höhe in Pixeln (150-400). Fotos werden skaliert, um in Zeilen zu passen.",
"lastRowBehavior": "Letzte Zeile Ausrichtung",
"lastRowOptions": {
"left": "Linksbündig",
"center": "Zentriert",
"justify": "Blocksatz (gestreckt)"
},
"showHeroSection": "Hero-Bereich anzeigen",
"showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an",
"heroHeight": "Hero-Bereich Höhe",
"heroHeightOptions": {
"small": "Klein (40-50%)",
"medium": "Mittel (50-70%)",
"large": "Groß (60-80%)"
},
"heroOverlayOpacity": "Hero-Overlay Deckkraft",
"heroOverlayHint": "Verdunkelt das Titelbild für bessere Lesbarkeit",
"typographyAndStyle": "Typografie & Stil",
"bodyFont": "Fließtext-Schriftart",
"headingFont": "Überschriften-Schriftart",
@@ -1209,7 +1367,54 @@
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
"livePreview": "Live-Vorschau"
"livePreview": "Live-Vorschau",
"whiteLabel": "White Label",
"hidePoweredBy": "\"Powered by PicPeak\" Branding ausblenden",
"hidePoweredByHelp": "Entfernen Sie die PicPeak-Kennzeichnung aus Galerie-Fußzeilen für ein vollständig personalisiertes Erscheinungsbild",
"logoCustomization": "Logo-Anpassung",
"changeLogo": "Logo ändern",
"logoSizeSmall": "Klein (32px)",
"logoSizeMedium": "Mittel (48px)",
"logoSizeLarge": "Groß (64px)",
"logoSizeXLarge": "Extra Groß (96px)",
"logoSizeCustom": "Benutzerdefiniert",
"logoMaxHeight": "Maximale Höhe (Pixel)",
"logoMaxHeightHelp": "Legen Sie eine benutzerdefinierte maximale Höhe für das Logo fest (20-200 Pixel)",
"logoPosition": "Logo-Position im Header",
"positionLeft": "Links",
"positionCenter": "Mitte",
"positionRight": "Rechts",
"logoDisplayMode": "Anzeigemodus",
"logoOnly": "Nur Logo",
"textOnly": "Nur Firmenname",
"logoAndText": "Logo und Firmenname",
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
"showLogoInHero": "Logo im Hero-Bereich anzeigen",
"showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)",
"headerStyle": "Kopfzeilen-Stil",
"headerStyleDescription": "Wählen Sie, wie die Galerie-Kopfzeile aussieht. Der Kopfzeilen-Stil ist unabhängig vom Foto-Layout.",
"headerStyleOptions": {
"hero": "Hero-Bild",
"standard": "Standard-Banner",
"minimal": "Minimal",
"none": "Keine Kopfzeile"
},
"headerStyleDescriptions": {
"hero": "Bild in voller Höhe mit Event-Info-Overlay",
"standard": "Klassisches Banner mit Veranstaltungsdetails",
"minimal": "Kompakte Kopfzeile mit wesentlichen Infos",
"none": "Kopfzeile komplett ausblenden"
},
"heroDividerStyle": "Trennlinie-Stil",
"heroDividerDescription": "Wählen Sie, wie der Übergang zwischen dem Hero-Bild und dem Galerie-Inhalt aussieht.",
"dividerOptions": {
"wave": "Welle",
"straight": "Gerade",
"angle": "Winkel",
"curve": "Kurve",
"none": "Keine"
}
},
"admin": {
"title": "Admin-Panel",
@@ -1232,6 +1437,20 @@
"error": "Fehler",
"checking": "Prüfe..."
},
"updates": {
"available": "Update verfügbar",
"newVersion": "Version {{version}} ist verfügbar",
"currentVersion": "Aktuell: {{version}}",
"channel": "Kanal: {{channel}}",
"channelStable": "Stabil",
"channelBeta": "Beta",
"beta": "BETA",
"viewReleaseNotes": "Versionshinweise anzeigen",
"updateAvailableShort": "v{{version}} verfügbar",
"checkForUpdates": "Nach Updates suchen",
"upToDate": "Alles aktuell",
"lastChecked": "Zuletzt geprüft: {{time}}"
},
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
@@ -1431,6 +1650,10 @@
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
"goToLogin": "Zur Anmeldung",
"contactAdminMessage": "Bitte kontaktieren Sie Ihren Administrator, um eine neue Einladung anzufordern.",
"passwordsMatch": "Passwörter stimmen überein",
"alreadyHaveAccount": "Bereits ein Konto?",
"signIn": "Anmelden",
"errors": {
"usernameRequired": "Benutzername ist erforderlich",
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",

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