Compare commits
104 Commits
v2.6.2
...
v3.28.2-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 59b56ed3d7 | |||
| 0a5b07de5d | |||
| b05c36ac81 | |||
| 9323befdd9 | |||
| 61142c0d0e | |||
| 623ab72916 | |||
| 3716ff5085 | |||
| dffe057772 | |||
| 3319a304ce | |||
| c303dd51e8 | |||
| b1dfbe4c2f | |||
| 54badefc51 | |||
| 15a8ab41fd | |||
| 77f07e9329 | |||
| 72c0c2d18e | |||
| 95d8bc4065 | |||
| 3856ba25bb | |||
| 9e1ba4f851 | |||
| b0efd32f7a | |||
| 9ed8a2b199 | |||
| ad4e5a7506 | |||
| d4b4dc628f | |||
| fe46e4268d | |||
| 1f3b9c6712 | |||
| 5295516b67 | |||
| ee0baafc59 | |||
| c63bc47089 | |||
| 147dc28440 | |||
| c031b1e863 | |||
| b1d16670d5 | |||
| ba1f010166 | |||
| ad64005a80 | |||
| 8805fa53e6 | |||
| 633d4a0f30 | |||
| b23c51b386 | |||
| 835bdf5abb | |||
| a1b63de251 | |||
| 97b1ae5b03 | |||
| dc98206737 | |||
| 40332a71db | |||
| 125cd0d003 | |||
| 83868ffe2f | |||
| 9ddd50f7e4 | |||
| bec36fc99f | |||
| ea50488e99 | |||
| 8614c2232c | |||
| 07fc5e6519 | |||
| 3c8d344ddd | |||
| edf8bd54af | |||
| 2b7c9b0138 | |||
| aef9b4ed7f | |||
| ee3f6ae13b | |||
| 5025a42bf7 | |||
| 0a7a89045b | |||
| ddefd3a95e | |||
| 978e4473b5 | |||
| 8c5996e4ec | |||
| f50d7c0c51 | |||
| 4ce8dd297a | |||
| 85a4eb90fd | |||
| e32da68cbd | |||
| b54a80d251 | |||
| 2ac6c51fe5 | |||
| 23cd9cb680 | |||
| a63f1a8dd9 | |||
| 954a0118ba | |||
| ee46088985 | |||
| 3742d71535 | |||
| 486239aeb9 | |||
| 2c5ae6fbb9 | |||
| f9889a93fb | |||
| 4a93e4e8cb | |||
| e1b6e43e52 | |||
| 999c66dbbf | |||
| f5997892c4 | |||
| 7ca96315e2 | |||
| f3622396e7 | |||
| 56cf60c570 | |||
| 2618415aa1 | |||
| dfae2c2bc6 | |||
| bbeedd1888 | |||
| 201965b4b1 | |||
| 1468c459ba | |||
| 088de43f09 | |||
| 1fa222e9c4 | |||
| 6aceb40595 | |||
| 431a82eca1 | |||
| 85a07fcca7 | |||
| 1f524f2358 | |||
| 48a025b915 | |||
| c652ae0ead | |||
| 9a6d2e8e3a | |||
| fc0911acf8 | |||
| f77802325a | |||
| 74c9a5fbcd | |||
| 703c03fbee | |||
| 6f95b8c26c | |||
| 7250c427b9 | |||
| 04a7ea80f9 | |||
| c0a5cd56c8 | |||
| 908ab08815 | |||
| f07602553c | |||
| d2663bff81 | |||
| 308e086263 |
@@ -7,6 +7,34 @@ NODE_ENV=production
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.17.1-beta.0"
|
||||
".": "3.28.2-beta.0"
|
||||
}
|
||||
|
||||
+437
-1
@@ -5,13 +5,449 @@ 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).
|
||||
|
||||
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
|
||||
## [3.28.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.1-beta.0...v3.28.2-beta.0) (2026-04-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([b05c36a](https://github.com/the-luap/picpeak/commit/b05c36ac810a557a2ac088ab7bec39bb76f9a2ae))
|
||||
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([9323bef](https://github.com/the-luap/picpeak/commit/9323befdd99d64b85cca89af24ac1b7034d72eee))
|
||||
|
||||
## [3.28.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.0-beta.0...v3.28.1-beta.0) (2026-04-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply sort direction in gallery and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([3716ff5](https://github.com/the-luap/picpeak/commit/3716ff50854766bde588fbd6b9027f8647e59150))
|
||||
* apply sort direction in gallery view and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([dffe057](https://github.com/the-luap/picpeak/commit/dffe057772c922ab6a213e25f171157e0c2badf8))
|
||||
|
||||
## [3.28.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.27.0-beta.0...v3.28.0-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([b1dfbe4](https://github.com/the-luap/picpeak/commit/b1dfbe4c2fe271d8087974d02cf724f04058bdc9))
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([15a8ab4](https://github.com/the-luap/picpeak/commit/15a8ab41fd1c94e3397d300b161cd1fdd459ea05))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([54badef](https://github.com/the-luap/picpeak/commit/54badefc51b834d55530722f87c81a6ade33e35b))
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([77f07e9](https://github.com/the-luap/picpeak/commit/77f07e9329e47f6ac5040f2e85d2710ebbea3ced))
|
||||
|
||||
## [3.27.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.2-beta.0...v3.27.0-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
|
||||
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
|
||||
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
|
||||
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
|
||||
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
|
||||
* 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))
|
||||
* 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))
|
||||
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
|
||||
* 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)
|
||||
* 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))
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
|
||||
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
|
||||
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
|
||||
* 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))
|
||||
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
|
||||
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
|
||||
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
|
||||
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
|
||||
* **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))
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* **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))
|
||||
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
|
||||
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
|
||||
* **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))
|
||||
* 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))
|
||||
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
|
||||
* **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))
|
||||
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
|
||||
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
|
||||
* **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))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
|
||||
* 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))
|
||||
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
|
||||
* **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))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
|
||||
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
|
||||
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
|
||||
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
|
||||
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* 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))
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
|
||||
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
|
||||
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
|
||||
* **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))
|
||||
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
|
||||
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
|
||||
* **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))
|
||||
* 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)
|
||||
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* **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))
|
||||
* 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)
|
||||
* 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))
|
||||
* **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))
|
||||
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
|
||||
* **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 compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
|
||||
* 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))
|
||||
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
|
||||
* 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)
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
|
||||
* **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 legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
|
||||
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
|
||||
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
|
||||
* 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))
|
||||
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
|
||||
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
* 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))
|
||||
* 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 ([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)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
* 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))
|
||||
* **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))
|
||||
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
|
||||
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
|
||||
* 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 database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* 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 non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
|
||||
* 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)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
* 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 code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
|
||||
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
|
||||
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
|
||||
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
|
||||
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
|
||||
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
|
||||
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
|
||||
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
|
||||
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
|
||||
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
|
||||
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
|
||||
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
|
||||
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
|
||||
* **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))
|
||||
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
|
||||
* 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)
|
||||
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
|
||||
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
|
||||
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
|
||||
* update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
* 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))
|
||||
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
|
||||
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
|
||||
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
|
||||
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
|
||||
|
||||
## [3.26.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.1-beta.0...v3.26.2-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
|
||||
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
|
||||
|
||||
## [3.26.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.0-beta.0...v3.26.1-beta.0) (2026-04-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
|
||||
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
|
||||
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
|
||||
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
|
||||
|
||||
## [3.26.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.25.0-beta.0...v3.26.0-beta.0) (2026-04-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
|
||||
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
|
||||
|
||||
## [3.25.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.1-beta.0...v3.25.0-beta.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
|
||||
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
|
||||
|
||||
## [3.24.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.0-beta.0...v3.24.1-beta.0) (2026-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
|
||||
|
||||
## [3.24.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.23.0-beta.0...v3.24.0-beta.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
|
||||
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
|
||||
|
||||
## [3.23.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.22.0-beta.0...v3.23.0-beta.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
|
||||
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
|
||||
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
|
||||
|
||||
## [3.22.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.1-beta.0...v3.22.0-beta.0) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
|
||||
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
|
||||
|
||||
## [3.21.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.0-beta.0...v3.21.1-beta.0) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
|
||||
|
||||
## [3.21.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.1-beta.0...v3.21.0-beta.0) (2026-03-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
|
||||
|
||||
## [3.20.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.0-beta.0...v3.20.1-beta.0) (2026-03-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
|
||||
|
||||
## [3.20.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.2-beta.0...v3.20.0-beta.0) (2026-03-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
|
||||
|
||||
## [3.19.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.1-beta.0...v3.19.2-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
|
||||
|
||||
## [3.19.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.0-beta.0...v3.19.1-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
|
||||
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
|
||||
|
||||
## [3.19.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.2-beta.0...v3.19.0-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
|
||||
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
|
||||
|
||||
## [3.18.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.1-beta.0...v3.18.2-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
|
||||
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
|
||||
|
||||
## [3.18.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.0-beta.0...v3.18.1-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
|
||||
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
|
||||
|
||||
## [3.18.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.2-beta.0...v3.18.0-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
|
||||
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
|
||||
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
|
||||
|
||||
## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
|
||||
## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08)
|
||||
|
||||
|
||||
+21
-14
@@ -219,29 +219,36 @@ location ~ ^/(photos|thumbnails|uploads) {
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Method 1: Via Admin Panel (Recommended)
|
||||
1. Login to admin panel
|
||||
#### Via Admin Panel
|
||||
1. Login to admin panel at `/admin`
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings and upload photos
|
||||
3. Configure settings (name, date, password, customer email)
|
||||
4. Upload photos via drag & drop in the Photos tab
|
||||
5. Publish the gallery when ready
|
||||
|
||||
#### Adding Photos via File System
|
||||
|
||||
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
|
||||
|
||||
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
|
||||
|
||||
#### Method 2: File System
|
||||
```bash
|
||||
# Docker installation
|
||||
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
|
||||
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
|
||||
# Docker installation — copy photos into an existing event's folder
|
||||
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
|
||||
|
||||
# Native installation
|
||||
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
|
||||
```
|
||||
|
||||
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
wedding-smith-2024/
|
||||
├── collages/ # Group photos
|
||||
├── individual/ # Individual photos
|
||||
└── thumbnails/ # Auto-generated thumbnails
|
||||
<event-slug>/
|
||||
├── collages/ # Group photos (optional subfolder)
|
||||
├── individual/ # Individual photos (optional subfolder)
|
||||
└── photo.jpg # Photos at root level also work
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
|
||||
@@ -9,6 +9,34 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# URLs (adjust for your domain)
|
||||
ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
@@ -23,15 +23,16 @@ RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
RUN npm install -g npm@latest
|
||||
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
|
||||
RUN npm install -g npm@10
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 074_add_photo_cap');
|
||||
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
|
||||
table.integer('photo_cap').nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Migration 074_add_photo_cap completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 074_add_photo_cap');
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add visibility column to photos table
|
||||
await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => {
|
||||
table.string('visibility', 20).defaultTo('visible').notNullable();
|
||||
});
|
||||
|
||||
// Add client access columns to events table
|
||||
await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => {
|
||||
table.boolean('client_access_enabled').defaultTo(false);
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => {
|
||||
table.string('client_password_hash', 255).nullable();
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => {
|
||||
table.string('client_share_token', 64).nullable().unique();
|
||||
});
|
||||
|
||||
// Index for filtering photos by visibility
|
||||
await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Safe rollback - intentionally no-op to avoid data loss
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Migration to create email_template_translations table
|
||||
* Moves from per-column language support (subject_en, subject_de) to a
|
||||
* normalized translations table where each language is a row.
|
||||
* This allows adding new languages without schema changes.
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// 1. Create the email_template_translations table
|
||||
await knex.schema.createTable('email_template_translations', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('template_id').unsigned().notNullable()
|
||||
.references('id').inTable('email_templates').onDelete('CASCADE');
|
||||
table.string('language', 10).notNullable();
|
||||
table.text('subject');
|
||||
table.text('body_html');
|
||||
table.text('body_text');
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['template_id', 'language']);
|
||||
});
|
||||
|
||||
console.log('Created email_template_translations table');
|
||||
|
||||
// 2. Migrate existing data from email_templates columns into rows
|
||||
const templates = await knex('email_templates').select('*');
|
||||
const columnInfo = await knex('email_templates').columnInfo();
|
||||
const hasLangColumns = !!columnInfo.subject_en;
|
||||
|
||||
for (const template of templates) {
|
||||
// Extract EN translation
|
||||
const enSubject = hasLangColumns
|
||||
? (template.subject_en || template.subject || '')
|
||||
: (template.subject || '');
|
||||
const enHtml = hasLangColumns
|
||||
? (template.body_html_en || template.body_html || '')
|
||||
: (template.body_html || '');
|
||||
const enText = hasLangColumns
|
||||
? (template.body_text_en || template.body_text || '')
|
||||
: (template.body_text || '');
|
||||
|
||||
// Insert EN translation
|
||||
if (enSubject || enHtml) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language: 'en',
|
||||
subject: enSubject,
|
||||
body_html: enHtml,
|
||||
body_text: enText,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract DE translation (only if lang columns exist)
|
||||
if (hasLangColumns) {
|
||||
const deSubject = template.subject_de || '';
|
||||
const deHtml = template.body_html_de || '';
|
||||
const deText = template.body_text_de || '';
|
||||
|
||||
// Only insert if DE content differs from EN or has content
|
||||
if (deSubject || deHtml) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language: 'de',
|
||||
subject: deSubject,
|
||||
body_html: deHtml,
|
||||
body_text: deText,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migrated ${templates.length} templates to translations table`);
|
||||
|
||||
// 3. Seed NL, PT, RU translations for customer-facing templates
|
||||
// Look up template IDs
|
||||
const customerTemplates = await knex('email_templates')
|
||||
.whereIn('template_key', [
|
||||
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
|
||||
])
|
||||
.select('id', 'template_key');
|
||||
|
||||
const templateMap = {};
|
||||
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
|
||||
|
||||
const seedTranslations = [];
|
||||
|
||||
// --- gallery_created ---
|
||||
if (templateMap.gallery_created) {
|
||||
const id = templateMap.gallery_created;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij is klaar!',
|
||||
body_html: `<h2>Galerij succesvol aangemaakt</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
|
||||
<p><strong>Galerij details:</strong></p>
|
||||
<ul>
|
||||
<li>Evenementdatum: {{event_date}}</li>
|
||||
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Wachtwoord: {{gallery_password}}</li>
|
||||
<li>Verloopt op: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos está pronta!',
|
||||
body_html: `<h2>Galeria criada com sucesso</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
|
||||
<p><strong>Detalhes da galeria:</strong></p>
|
||||
<ul>
|
||||
<li>Data do evento: {{event_date}}</li>
|
||||
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Senha: {{gallery_password}}</li>
|
||||
<li>Expira em: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Ваша фотогалерея готова!',
|
||||
body_html: `<h2>Галерея успешно создана</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
|
||||
<p><strong>Детали галереи:</strong></p>
|
||||
<ul>
|
||||
<li>Дата события: {{event_date}}</li>
|
||||
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Пароль: {{gallery_password}}</li>
|
||||
<li>Срок действия: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- expiration_warning ---
|
||||
if (templateMap.expiration_warning) {
|
||||
const id = templateMap.expiration_warning;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij verloopt binnenkort',
|
||||
body_html: `<h2>Galerij verloopt binnenkort</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
|
||||
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
|
||||
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
|
||||
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos expira em breve',
|
||||
body_html: `<h2>Galeria expirando em breve</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
|
||||
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
|
||||
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
|
||||
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Срок действия вашей фотогалереи скоро истекает',
|
||||
body_html: `<h2>Срок действия галереи истекает</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
|
||||
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
|
||||
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
|
||||
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- gallery_expired ---
|
||||
if (templateMap.gallery_expired) {
|
||||
const id = templateMap.gallery_expired;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Uw fotogalerij {{event_name}} is verlopen',
|
||||
body_html: `<h2>Galerij verlopen</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
|
||||
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
|
||||
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Sua galeria de fotos {{event_name}} expirou',
|
||||
body_html: `<h2>Galeria expirada</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
|
||||
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
|
||||
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Срок действия фотогалереи {{event_name}} истёк',
|
||||
body_html: `<h2>Срок действия галереи истёк</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
|
||||
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
|
||||
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- archive_complete ---
|
||||
if (templateMap.archive_complete) {
|
||||
const id = templateMap.archive_complete;
|
||||
seedTranslations.push(
|
||||
{
|
||||
template_id: id, language: 'nl',
|
||||
subject: 'Archivering voltooid: {{event_name}}',
|
||||
body_html: `<h2>Archivering voltooid</h2>
|
||||
<p>Beste {{host_name}},</p>
|
||||
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
|
||||
<p><strong>Archief details:</strong></p>
|
||||
<ul>
|
||||
<li>Aantal foto's: {{photo_count}}</li>
|
||||
<li>Archiefgrootte: {{archive_size}}</li>
|
||||
<li>Archiefdatum: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'pt',
|
||||
subject: 'Arquivamento concluído: {{event_name}}',
|
||||
body_html: `<h2>Arquivamento concluído</h2>
|
||||
<p>Prezado(a) {{host_name}},</p>
|
||||
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
|
||||
<p><strong>Detalhes do arquivo:</strong></p>
|
||||
<ul>
|
||||
<li>Número de fotos: {{photo_count}}</li>
|
||||
<li>Tamanho do arquivo: {{archive_size}}</li>
|
||||
<li>Data do arquivamento: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
|
||||
},
|
||||
{
|
||||
template_id: id, language: 'ru',
|
||||
subject: 'Архивация завершена: {{event_name}}',
|
||||
body_html: `<h2>Архивация завершена</h2>
|
||||
<p>Уважаемый(ая) {{host_name}},</p>
|
||||
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
|
||||
<p><strong>Детали архива:</strong></p>
|
||||
<ul>
|
||||
<li>Количество фото: {{photo_count}}</li>
|
||||
<li>Размер архива: {{archive_size}}</li>
|
||||
<li>Дата архивации: {{archive_date}}</li>
|
||||
</ul>`,
|
||||
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Insert all seed translations
|
||||
const now = new Date();
|
||||
for (const trans of seedTranslations) {
|
||||
trans.created_at = now;
|
||||
trans.updated_at = now;
|
||||
await knex('email_template_translations').insert(trans);
|
||||
}
|
||||
|
||||
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('email_template_translations');
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Migration to add is_draft column to events table.
|
||||
* Draft events are not visible to gallery visitors until published.
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.boolean('is_draft').defaultTo(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('is_draft');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
exports.up = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('default_photo_sort', 50).defaultTo('upload_date_desc');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('default_photo_sort');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Add guest identity layer for per-person photo selections (issue #292).
|
||||
*
|
||||
* Adds:
|
||||
* - gallery_guests — persistent guest profiles per event
|
||||
* - guest_invites — pre-minted invite tokens (Phase 3.3)
|
||||
* - guest_verification_codes — email-based identity recovery (Phase 3.2)
|
||||
* - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple')
|
||||
* - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable)
|
||||
*
|
||||
* All changes are additive. Existing events default to 'simple' mode so behavior
|
||||
* is unchanged. Legacy photo_feedback rows keep NULL guest_id.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. gallery_guests — persistent per-person identity within an event.
|
||||
const hasGalleryGuests = await knex.schema.hasTable('gallery_guests');
|
||||
if (!hasGalleryGuests) {
|
||||
await knex.schema.createTable('gallery_guests', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('email', 255);
|
||||
table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side
|
||||
table.string('ip_address_last', 45);
|
||||
table.text('user_agent_last');
|
||||
table.timestamp('email_verified_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('last_seen_at').defaultTo(knex.fn.now());
|
||||
table.boolean('is_deleted').defaultTo(false);
|
||||
|
||||
table.unique(['event_id', 'identifier']);
|
||||
table.index(['event_id']);
|
||||
table.index(['event_id', 'email']);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. guest_invites — pre-minted one-time-use tokens for invited guests.
|
||||
const hasGuestInvites = await knex.schema.hasTable('guest_invites');
|
||||
if (!hasGuestInvites) {
|
||||
await knex.schema.createTable('guest_invites', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE');
|
||||
table.string('token', 64).notNullable().unique();
|
||||
table.integer('created_by_admin_id').references('id').inTable('admin_users');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('redeemed_at');
|
||||
table.timestamp('revoked_at');
|
||||
|
||||
table.index(['event_id']);
|
||||
table.index(['guest_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. guest_verification_codes — short-lived codes for email-based recovery.
|
||||
const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes');
|
||||
if (!hasGuestVerificationCodes) {
|
||||
await knex.schema.createTable('guest_verification_codes', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('email', 255).notNullable();
|
||||
table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code
|
||||
table.integer('attempts').defaultTo(0);
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('consumed_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['event_id', 'email']);
|
||||
table.index(['expires_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. event_feedback_settings.identity_mode
|
||||
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
|
||||
if (!hasIdentityMode) {
|
||||
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||
table.string('identity_mode', 16).notNullable().defaultTo('simple');
|
||||
});
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw(`
|
||||
ALTER TABLE event_feedback_settings
|
||||
ADD CONSTRAINT event_feedback_settings_identity_mode_check
|
||||
CHECK (identity_mode IN ('simple','guest'))
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. photo_feedback.guest_id FK
|
||||
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
|
||||
if (!hasGuestIdColumn) {
|
||||
await knex.schema.alterTable('photo_feedback', (table) => {
|
||||
table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL');
|
||||
table.index(['guest_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
|
||||
if (hasGuestIdColumn) {
|
||||
await knex.schema.alterTable('photo_feedback', (table) => {
|
||||
table.dropColumn('guest_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check');
|
||||
}
|
||||
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
|
||||
if (hasIdentityMode) {
|
||||
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||
table.dropColumn('identity_mode');
|
||||
});
|
||||
}
|
||||
|
||||
await knex.schema.dropTableIfExists('guest_verification_codes');
|
||||
await knex.schema.dropTableIfExists('guest_invites');
|
||||
await knex.schema.dropTableIfExists('gallery_guests');
|
||||
};
|
||||
Generated
+95
-60
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.24.1-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.24.1-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -26,7 +26,7 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -39,7 +39,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -4071,14 +4071,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
@@ -4290,9 +4290,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -5788,6 +5788,12 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
@@ -5832,21 +5838,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
|
||||
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5855,8 +5849,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.0.0",
|
||||
"strnum": "^2.1.2"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.10",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
|
||||
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.1",
|
||||
"strnum": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -5986,9 +5996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -6418,9 +6428,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@@ -6664,6 +6674,19 @@
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -7977,9 +8000,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
@@ -8558,9 +8581,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -8760,9 +8783,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz",
|
||||
"integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==",
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9145,6 +9168,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz",
|
||||
"integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -9185,12 +9223,6 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
@@ -9579,10 +9611,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
@@ -10659,9 +10694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10760,9 +10795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.1",
|
||||
"version": "3.28.2-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -31,7 +31,7 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -67,10 +67,12 @@
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"fast-xml-parser": ">=5.3.8",
|
||||
"fast-xml-parser": ">=5.5.10",
|
||||
"qs": ">=6.14.2",
|
||||
"tar": ">=7.5.8",
|
||||
"brace-expansion": ">=5.0.0",
|
||||
"minimatch": ">=9.0.7"
|
||||
"tar": ">=7.5.13",
|
||||
"brace-expansion": ">=5.0.5",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
"lodash": ">=4.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-8
@@ -194,13 +194,23 @@ function composeInlineStyles(payload) {
|
||||
return cssSegments.join('\n\n');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderBrandHeader(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
||||
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
|
||||
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||
|
||||
const tagline = branding.companyTagline
|
||||
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
|
||||
: '';
|
||||
|
||||
return `<header class="site-header">
|
||||
@@ -224,13 +234,14 @@ function renderBrandHeader(branding) {
|
||||
}
|
||||
|
||||
function renderBrandFooter(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
||||
const footerNote = branding.footerText
|
||||
? `<p>${branding.footerText}</p>`
|
||||
? `<p>${escapeHtml(branding.footerText)}</p>`
|
||||
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||
|
||||
const supportLink = branding.supportEmail
|
||||
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||
const supportEmail = escapeHtml(branding.supportEmail || '');
|
||||
const supportLink = supportEmail
|
||||
? `<a href="mailto:${supportEmail}">Support</a>`
|
||||
: '';
|
||||
|
||||
const legalLinks = `
|
||||
@@ -282,7 +293,7 @@ function buildPublicSiteDocument(payload) {
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<title>${escapeHtml(payload.title)}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
${seoMeta}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -362,6 +373,20 @@ async function initializeRateLimiters() {
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
|
||||
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
try {
|
||||
@@ -487,12 +512,14 @@ app.use('/api/auth', authRoutes);
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/gallery', require('./src/routes/galleryGuests'));
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin', require('./src/routes/adminGuests'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
|
||||
|
||||
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for the guest
|
||||
* Generate a unique identifier for the guest.
|
||||
*
|
||||
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
|
||||
* unique per person per event (set by the resolveGuest middleware). When
|
||||
* present it takes precedence, so rate limits and deduplication become
|
||||
* per-person instead of per-device.
|
||||
*
|
||||
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
|
||||
* matching prior behavior.
|
||||
*/
|
||||
function generateGuestIdentifier(req) {
|
||||
if (req.guest && req.guest.identifier) {
|
||||
return req.guest.identifier;
|
||||
}
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
return crypto
|
||||
|
||||
@@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Check if the request carries a valid admin preview token (Feature 3)
|
||||
function isAdminPreview(req) {
|
||||
const previewToken = req.query?.preview;
|
||||
if (!previewToken) return false;
|
||||
try {
|
||||
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
return decoded.type === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
@@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const adminPreview = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
const q = db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreview) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
const adminPreviewToken = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
const q = db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreviewToken) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
|
||||
// Verify the token's eventId matches
|
||||
@@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
} else {
|
||||
// Fallback to using eventId from token
|
||||
const adminPreviewFallback = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
const q = db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreviewFallback) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,8 +123,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.accessLevel = decoded.accessLevel || 'guest';
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
|
||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
@@ -121,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyGalleryAccess
|
||||
verifyGalleryAccess,
|
||||
isAdminPreview
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Non-blocking middleware. Reads an optional guest token from the request and,
|
||||
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
|
||||
*
|
||||
* If the token is missing, malformed, or expired → req.guest = null and the
|
||||
* request continues. Downstream handlers (e.g. feedback submission) enforce
|
||||
* presence explicitly based on event feedback settings (identity_mode).
|
||||
*/
|
||||
async function resolveGuest(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug;
|
||||
const token = getGuestTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
// Invalid or expired guest tokens are silently ignored so that public
|
||||
// gallery browsing continues to work even if the token is stale.
|
||||
logger.debug('Invalid guest token', { reason: err.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (decoded.type !== 'guest') {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Verify the guest row still exists and is not soft-deleted.
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (!guest) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
req.guest = {
|
||||
id: guest.id,
|
||||
eventId: guest.event_id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
email: guest.email || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('resolveGuest middleware error', { error: error.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking middleware that 401s if no guest identity was resolved.
|
||||
* Use this on endpoints that require a valid guest session.
|
||||
*/
|
||||
function requireGuest(req, res, next) {
|
||||
if (!req.guest) {
|
||||
return res.status(401).json({ error: 'Guest identity required' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a new guest JWT. Scoped to a specific event and guest row.
|
||||
* Expiry matches the gallery token default (24h).
|
||||
*/
|
||||
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
|
||||
return jwt.sign(
|
||||
{
|
||||
type: 'guest',
|
||||
guestId,
|
||||
eventId,
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{
|
||||
issuer: 'picpeak-auth',
|
||||
expiresIn,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveGuest,
|
||||
requireGuest,
|
||||
signGuestToken,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
/**
|
||||
* Middleware to enforce event ownership for non-super_admin users.
|
||||
* Super admins bypass the check. Other admins can only access events they created.
|
||||
*/
|
||||
function requireEventOwnership(req, res, next) {
|
||||
if (req.admin.roleName === 'super_admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const eventId = req.params.eventId || req.params.id;
|
||||
if (!eventId) {
|
||||
return res.status(400).json({ error: 'Event ID is required' });
|
||||
}
|
||||
|
||||
db('events')
|
||||
.where('id', eventId)
|
||||
.first()
|
||||
.then((event) => {
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
// Allow access if: event has no owner (legacy/system), or admin owns it
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(500).json({ error: 'Failed to verify ownership' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership };
|
||||
@@ -85,18 +85,28 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
const now = Date.now();
|
||||
const lastActivity = sessions.get(token);
|
||||
const timeout = await getSessionTimeout();
|
||||
|
||||
// If session exists, check if it's expired
|
||||
|
||||
if (lastActivity) {
|
||||
// Existing session — check if idle too long
|
||||
if (now - lastActivity > timeout) {
|
||||
sessions.delete(token);
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// First request with this token — check if token was issued longer ago than the timeout
|
||||
// This prevents old/stolen tokens from bypassing session timeout after server restart
|
||||
const tokenIssuedAt = (decoded.iat || 0) * 1000; // iat is in seconds
|
||||
if (now - tokenIssuedAt > timeout) {
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update last activity
|
||||
sessions.set(token, now);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
@@ -82,7 +83,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
});
|
||||
|
||||
// Get single archive details
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -138,7 +139,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, re
|
||||
});
|
||||
|
||||
// Restore archive
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -301,7 +302,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), as
|
||||
});
|
||||
|
||||
// Download archive
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -350,7 +351,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), a
|
||||
});
|
||||
|
||||
// Delete archive permanently
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -7,6 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -122,15 +124,36 @@ router.post('/change-password', [
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
// Update password, set password_changed_at to invalidate existing tokens, and clear must_change_password flag
|
||||
const now = new Date();
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
password_changed_at: now,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
updated_at: now
|
||||
});
|
||||
|
||||
// Issue a new token so the session remains valid after password_changed_at invalidated the old one.
|
||||
// Set iat to 1 second after password_changed_at to guarantee the token passes the
|
||||
// "iat < password_changed_at" check in auth middleware (password_changed_at has ms precision
|
||||
// but JWT iat is floored to seconds, which can cause the new token to be rejected).
|
||||
const iatAfterPasswordChange = Math.floor(now.getTime() / 1000) + 1;
|
||||
const newToken = jwt.sign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
type: 'admin',
|
||||
role: user.role_name,
|
||||
iat: iatAfterPasswordChange,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, newToken);
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
|
||||
@@ -258,6 +258,13 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
||||
break;
|
||||
}
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
@@ -355,12 +362,17 @@ router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view')
|
||||
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath } = req.body;
|
||||
|
||||
|
||||
if (!manifestPath) {
|
||||
return res.status(400).json({ error: 'manifestPath is required' });
|
||||
}
|
||||
|
||||
const result = await validateBackupManifest(manifestPath);
|
||||
|
||||
// Prevent path traversal — manifest must be within backup directory
|
||||
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
const safePath = safePathJoin(backupBasePath, manifestPath);
|
||||
|
||||
const result = await validateBackupManifest(safePath);
|
||||
|
||||
res.json({
|
||||
valid: result.valid,
|
||||
@@ -456,19 +468,24 @@ router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup
|
||||
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath, manifestData } = req.body;
|
||||
|
||||
|
||||
if (!manifestPath && !manifestData) {
|
||||
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
|
||||
}
|
||||
|
||||
|
||||
if (manifestData) {
|
||||
// Validate provided manifest data directly
|
||||
const validationResult = await validateManifestData(manifestData);
|
||||
return res.json(validationResult);
|
||||
}
|
||||
|
||||
|
||||
// Prevent path traversal — manifest must be within backup directory
|
||||
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
const safePath = safePathJoin(backupBasePath, manifestPath);
|
||||
|
||||
// Use existing validation function for path
|
||||
const result = await validateBackupManifest(manifestPath);
|
||||
const result = await validateBackupManifest(safePath);
|
||||
|
||||
res.json({
|
||||
valid: result.valid,
|
||||
@@ -757,10 +774,14 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
|
||||
try {
|
||||
const { path: targetPath = '', recursive = true } = req.query;
|
||||
const checksums = {};
|
||||
|
||||
|
||||
// Get storage path
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
|
||||
let basePath = storagePath;
|
||||
if (targetPath) {
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
basePath = safePathJoin(storagePath, targetPath);
|
||||
}
|
||||
|
||||
// Calculate checksums for files
|
||||
async function calculateDirChecksums(dirPath, relative = '') {
|
||||
|
||||
+198
-148
@@ -4,6 +4,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { wrapEmailHtml } = require('../services/emailProcessor');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -60,6 +61,12 @@ router.post('/config', [
|
||||
tls_reject_unauthorized
|
||||
} = req.body;
|
||||
|
||||
// Validate SMTP host is not a private/internal address (SSRF protection)
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(smtp_host)) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
|
||||
// Check if config exists
|
||||
const existingConfig = await db('email_configs').first();
|
||||
|
||||
@@ -159,22 +166,26 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
|
||||
const transporter = nodemailer.createTransport(transportConfig);
|
||||
|
||||
// Send test email
|
||||
// Send test email with the same wrapper used for all other emails
|
||||
const subject = 'Test Email - Photo Sharing Platform';
|
||||
const testHtmlBody = `
|
||||
<h2>Test Email Successful!</h2>
|
||||
<p>This is a test email from your Photo Sharing platform.</p>
|
||||
<p>If you're seeing this, your email configuration is working correctly.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Sent from: ${config.from_email}<br>
|
||||
SMTP Host: ${config.smtp_host}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`;
|
||||
const wrappedHtml = await wrapEmailHtml(testHtmlBody, subject);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
to: test_email,
|
||||
subject: 'Test Email - Photo Sharing Platform',
|
||||
html: `
|
||||
<h2>Test Email Successful!</h2>
|
||||
<p>This is a test email from your Photo Sharing platform.</p>
|
||||
<p>If you're seeing this, your email configuration is working correctly.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Sent from: ${config.from_email}<br>
|
||||
SMTP Host: ${config.smtp_host}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`,
|
||||
subject,
|
||||
html: wrappedHtml,
|
||||
text: 'Test Email Successful! Your email configuration is working correctly.'
|
||||
});
|
||||
|
||||
@@ -237,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: get translations for a template, with legacy column fallback
|
||||
async function getTemplateTranslations(templateId, template) {
|
||||
const translations = {};
|
||||
try {
|
||||
const rows = await db('email_template_translations')
|
||||
.where('template_id', templateId)
|
||||
.select('language', 'subject', 'body_html', 'body_text');
|
||||
|
||||
rows.forEach(row => {
|
||||
translations[row.language] = {
|
||||
subject: row.subject || '',
|
||||
body_html: row.body_html || '',
|
||||
body_text: row.body_text || '',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Translations table might not exist yet (pre-migration)
|
||||
// Fall back to legacy columns
|
||||
if (template.subject_en !== undefined) {
|
||||
translations.en = {
|
||||
subject: template.subject_en || '',
|
||||
body_html: template.body_html_en || '',
|
||||
body_text: template.body_text_en || '',
|
||||
};
|
||||
translations.de = {
|
||||
subject: template.subject_de || '',
|
||||
body_html: template.body_html_de || '',
|
||||
body_text: template.body_text_de || '',
|
||||
};
|
||||
} else {
|
||||
translations.en = {
|
||||
subject: template.subject || '',
|
||||
body_html: template.body_html || '',
|
||||
body_text: template.body_text || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -244,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
.select('*')
|
||||
.orderBy('template_key');
|
||||
|
||||
// Parse variables JSON and format for multi-language support
|
||||
const formattedTemplates = templates.map(template => {
|
||||
const result = {
|
||||
const formattedTemplates = [];
|
||||
for (const template of templates) {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
formattedTemplates.push({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Handle both old and new schema formats
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
result.subject_en = template.subject_en;
|
||||
result.body_html_en = template.body_html_en;
|
||||
result.body_text_en = template.body_text_en;
|
||||
result.subject_de = template.subject_de;
|
||||
result.body_html_de = template.body_html_de;
|
||||
result.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
result.subject_en = template.subject;
|
||||
result.body_html_en = template.body_html;
|
||||
result.body_text_en = template.body_text;
|
||||
result.subject_de = template.subject;
|
||||
result.body_html_de = template.body_html;
|
||||
result.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
@@ -302,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Handle both old and new schema formats
|
||||
const response = {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Check which columns exist and use them appropriately
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
response.subject_en = template.subject_en;
|
||||
response.body_html_en = template.body_html_en;
|
||||
response.body_text_en = template.body_text_en;
|
||||
response.subject_de = template.subject_de;
|
||||
response.body_html_de = template.body_html_de;
|
||||
response.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
response.subject_en = template.subject;
|
||||
response.body_html_en = template.body_html;
|
||||
response.body_text_en = template.body_text;
|
||||
response.subject_de = template.subject;
|
||||
response.body_html_de = template.body_html;
|
||||
response.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update email template
|
||||
// Update email template translations
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
subject_en, subject_de,
|
||||
body_html_en, body_html_de,
|
||||
body_text_en, body_text_de
|
||||
} = req.body;
|
||||
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
// Check which columns exist in the database
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.first();
|
||||
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Determine schema type and update accordingly
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
if (subject_en !== undefined) updateData.subject_en = subject_en;
|
||||
if (subject_de !== undefined) updateData.subject_de = subject_de;
|
||||
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
|
||||
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
|
||||
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
|
||||
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
|
||||
|
||||
// Also update basic columns if they exist
|
||||
if (template.subject !== undefined) {
|
||||
updateData.subject = subject_en || updateData.subject_en;
|
||||
updateData.body_html = body_html_en || updateData.body_html_en;
|
||||
updateData.body_text = body_text_en || updateData.body_text_en || '';
|
||||
}
|
||||
} else {
|
||||
// Old schema - only update basic columns
|
||||
if (subject_en !== undefined) {
|
||||
updateData.subject = subject_en;
|
||||
updateData.body_html = body_html_en;
|
||||
updateData.body_text = body_text_en || '';
|
||||
const { translations } = req.body;
|
||||
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
// Upsert each language translation
|
||||
for (const [language, data] of Object.entries(translations)) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
const existing = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
const row = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.update(row);
|
||||
} else {
|
||||
await db('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language,
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.update(updateData);
|
||||
// Update timestamp on parent template
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update({ updated_at: new Date() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
// Also sync legacy columns for backward compatibility
|
||||
const enData = translations.en;
|
||||
const deData = translations.de;
|
||||
const legacyUpdate = { updated_at: new Date() };
|
||||
const columnInfo = await db('email_templates').columnInfo();
|
||||
|
||||
if (enData && columnInfo.subject_en) {
|
||||
legacyUpdate.subject_en = enData.subject || '';
|
||||
legacyUpdate.body_html_en = enData.body_html || '';
|
||||
legacyUpdate.body_text_en = enData.body_text || '';
|
||||
}
|
||||
if (deData && columnInfo.subject_de) {
|
||||
legacyUpdate.subject_de = deData.subject || '';
|
||||
legacyUpdate.body_html_de = deData.body_html || '';
|
||||
legacyUpdate.body_text_de = deData.body_text || '';
|
||||
}
|
||||
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update(legacyUpdate);
|
||||
|
||||
// Log activity
|
||||
await logActivity('email_template_updated',
|
||||
{ template_key: req.params.key },
|
||||
{ template_key: req.params.key, languages: Object.keys(translations) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -438,29 +453,64 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
}
|
||||
|
||||
const { preview_data, language = 'en' } = req.body;
|
||||
|
||||
// Get the appropriate language version
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Handle backward compatibility
|
||||
let htmlContent = template[htmlField] || template.body_html || '';
|
||||
let textContent = template[textField] || template.body_text || '';
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
|
||||
// Get translation from translations table with fallback
|
||||
let translation = null;
|
||||
try {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to legacy columns
|
||||
}
|
||||
|
||||
let subject = '';
|
||||
let htmlContent = '';
|
||||
let textContent = '';
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlContent = translation.body_html || '';
|
||||
textContent = translation.body_text || '';
|
||||
} else {
|
||||
// Legacy column fallback
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject || '';
|
||||
htmlContent = template[htmlField] || template.body_html || '';
|
||||
textContent = template[textField] || template.body_text || '';
|
||||
}
|
||||
|
||||
if (preview_data) {
|
||||
const escapeHtml = (str) => String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
Object.keys(preview_data).forEach(key => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
htmlContent = htmlContent.replace(regex, preview_data[key]);
|
||||
const escapedValue = escapeHtml(preview_data[key]);
|
||||
htmlContent = htmlContent.replace(regex, escapedValue);
|
||||
textContent = textContent.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, escapeHtml(preview_data[key]));
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap in the full styled email template with header/footer/logo
|
||||
const wrappedHtml = await wrapEmailHtml(htmlContent, subject, language);
|
||||
|
||||
res.json({
|
||||
subject,
|
||||
body_html: htmlContent,
|
||||
body_html: wrappedHtml,
|
||||
body_text: textContent,
|
||||
language
|
||||
});
|
||||
|
||||
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null
|
||||
upload_category_id = null,
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
|
||||
@@ -20,6 +20,8 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
@@ -110,6 +112,50 @@ const getEventFieldRequirements = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance)
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size',
|
||||
'branding_logo_position'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_position' && value) {
|
||||
defaults.hero_logo_position = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
@@ -124,6 +170,8 @@ const mapEventForApi = (event) => {
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
password_hash: _ph,
|
||||
client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
@@ -208,7 +256,15 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString(),
|
||||
body('default_photo_sort').optional().isIn([
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -256,7 +312,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
header_style = 'standard',
|
||||
hero_divider_style = 'wave',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center'
|
||||
hero_image_anchor = 'center',
|
||||
// Photo cap
|
||||
photo_cap = null,
|
||||
// Client access settings (#172)
|
||||
client_access_enabled = false,
|
||||
client_password = null,
|
||||
// Draft mode
|
||||
is_draft = true,
|
||||
// Default photo sort
|
||||
default_photo_sort = 'upload_date_desc'
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -385,6 +450,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
}
|
||||
}
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
|
||||
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
@@ -411,12 +482,21 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
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',
|
||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center'
|
||||
hero_image_anchor: hero_image_anchor || 'center',
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
|
||||
default_photo_sort: default_photo_sort || 'upload_date_desc',
|
||||
// Client access (#172)
|
||||
client_access_enabled: formatBoolean(client_access_enabled),
|
||||
...(client_access_enabled && client_password ? {
|
||||
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||
} : {})
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -446,25 +526,37 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue creation email (only if there is a recipient)
|
||||
// Queue creation email (only if there is a recipient and event is not a draft)
|
||||
// Language detection is handled by email processor
|
||||
const isDraft = parseBooleanInput(is_draft, true);
|
||||
|
||||
if (customerEmail && !isDraft) {
|
||||
// Build email data with optional client access info
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
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 ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
};
|
||||
|
||||
// Include client access info in email when enabled (#172)
|
||||
if (client_access_enabled && client_password) {
|
||||
const createdEvent = await db('events').where('id', eventId).first();
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
|
||||
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
|
||||
emailData.client_password = client_password;
|
||||
}
|
||||
|
||||
if (customerEmail) {
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
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 ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
@@ -479,6 +571,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: isDraft,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
@@ -526,6 +620,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
} else if (status === 'inactive') {
|
||||
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
|
||||
} else if (status === 'draft') {
|
||||
query = query.where('is_draft', formatBoolean(true));
|
||||
} else if (status === 'expiring') {
|
||||
const sevenDaysFromNow = new Date();
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
@@ -650,8 +746,67 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// Publish a draft event (set is_draft=false and queue creation email)
|
||||
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const event = await db('events').where('id', id).first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!parseBooleanInput(event.is_draft, false)) {
|
||||
return res.status(400).json({ error: 'Event is already published' });
|
||||
}
|
||||
|
||||
// Set is_draft to false
|
||||
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
|
||||
|
||||
// Queue creation email
|
||||
const customerEmail = event.customer_email || event.host_email;
|
||||
const customerName = event.customer_name || event.host_name;
|
||||
if (customerEmail) {
|
||||
const frontendBase = await getFrontendBaseUrl();
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date,
|
||||
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
|
||||
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
|
||||
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
welcome_message: event.welcome_message || ''
|
||||
};
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: id,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('event_published',
|
||||
{ event_name: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event published successfully', is_draft: false });
|
||||
} catch (error) {
|
||||
logger.error('Error publishing event:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to publish event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
body('admin_email').optional().isEmail(),
|
||||
body('is_active').optional().isBoolean(),
|
||||
@@ -705,7 +860,16 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString(),
|
||||
body('regenerate_client_token').optional().isBoolean(),
|
||||
body('default_photo_sort').optional().isIn([
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -784,6 +948,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Handle client access fields (#172)
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
||||
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
||||
// Auto-generate client share token when first enabling
|
||||
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
|
||||
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
|
||||
delete updates.client_password;
|
||||
} else {
|
||||
delete updates.client_password;
|
||||
}
|
||||
if (updates.regenerate_client_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
delete updates.regenerate_client_token;
|
||||
|
||||
// Log the update request for debugging
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
@@ -874,7 +1057,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
});
|
||||
|
||||
// Delete event
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -965,7 +1148,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
});
|
||||
|
||||
// Toggle event status
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1005,7 +1188,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), a
|
||||
});
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
@@ -1072,7 +1255,7 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1156,7 +1339,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1266,7 +1449,7 @@ 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) => {
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1321,7 +1504,7 @@ router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoU
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
const stats = await fs.stat(f.full);
|
||||
|
||||
// Extract dimensions via Sharp
|
||||
let width = null;
|
||||
let height = null;
|
||||
try {
|
||||
const metadata = await sharp(f.full).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
} catch (dimErr) {
|
||||
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
|
||||
}
|
||||
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
thumbnail_path: null,
|
||||
type,
|
||||
size_bytes: stats.size,
|
||||
width,
|
||||
height,
|
||||
source_origin: 'external',
|
||||
external_relpath: f.rel
|
||||
})
|
||||
|
||||
@@ -12,11 +12,13 @@ const {
|
||||
validateWordFilter,
|
||||
checkValidation
|
||||
} = require('../utils/feedbackValidation');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
|
||||
// Get event feedback settings
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -42,6 +44,7 @@ router.get('/events/:eventId/feedback-settings',
|
||||
router.put('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
validateFeedbackSettings,
|
||||
checkValidation,
|
||||
@@ -79,6 +82,7 @@ router.put('/events/:eventId/feedback-settings',
|
||||
router.get('/events/:eventId/feedback',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -204,6 +208,7 @@ router.delete('/feedback/:feedbackId',
|
||||
router.get('/events/:eventId/feedback-analytics',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -304,6 +309,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
router.get('/events/:eventId/feedback/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const router = express.Router();
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || '';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async function loadGuestOr404(eventId, guestId, res) {
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: guestId, event_id: eventId, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
res.status(404).json({ error: 'Guest not found' });
|
||||
return null;
|
||||
}
|
||||
return guest;
|
||||
}
|
||||
|
||||
function serializeGuest(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
email_verified_at: row.email_verified_at,
|
||||
is_deleted: row.is_deleted,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const str = value == null ? '' : String(value);
|
||||
if (/[,"\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests — list guests with aggregated counts
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const rows = await db('gallery_guests')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
|
||||
})
|
||||
.where('gallery_guests.event_id', eventId)
|
||||
.where('gallery_guests.is_deleted', false)
|
||||
.groupBy('gallery_guests.id')
|
||||
.select(
|
||||
'gallery_guests.id',
|
||||
'gallery_guests.name',
|
||||
'gallery_guests.email',
|
||||
'gallery_guests.created_at',
|
||||
'gallery_guests.last_seen_at',
|
||||
'gallery_guests.email_verified_at',
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
|
||||
const guests = rows.map((r) => ({
|
||||
...serializeGuest(r),
|
||||
stats: {
|
||||
likes: parseInt(r.likes, 10) || 0,
|
||||
favorites: parseInt(r.favorites, 10) || 0,
|
||||
comments: parseInt(r.comments, 10) || 0,
|
||||
ratings: parseInt(r.ratings, 10) || 0,
|
||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ guests });
|
||||
} catch (error) {
|
||||
logger.error('Error listing guests:', error);
|
||||
res.status(500).json({ error: 'Failed to list guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
|
||||
// guest pick count (Phase 2 aggregate view)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/aggregate',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.photo_id', '=', 'photos.id')
|
||||
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
|
||||
.andOnNotNull('photo_feedback.guest_id');
|
||||
})
|
||||
.where('photos.event_id', eventId)
|
||||
.groupBy('photos.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
|
||||
)
|
||||
.orderBy('picker_count', 'desc')
|
||||
.orderBy('photos.id', 'desc');
|
||||
|
||||
res.json({
|
||||
photos: photos
|
||||
.filter((p) => parseInt(p.picker_count, 10) > 0)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
filename: p.filename,
|
||||
original_filename: p.original_filename,
|
||||
url: `/api/admin/photos/${eventId}/photo/${p.id}`,
|
||||
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${p.id}`,
|
||||
picker_count: parseInt(p.picker_count, 10),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching aggregate view:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aggregate view' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
|
||||
const rows = await db('guest_invites')
|
||||
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
|
||||
.where('guest_invites.event_id', eventId)
|
||||
.select(
|
||||
'guest_invites.id',
|
||||
'guest_invites.token',
|
||||
'guest_invites.created_at',
|
||||
'guest_invites.redeemed_at',
|
||||
'guest_invites.revoked_at',
|
||||
'gallery_guests.id as guest_id',
|
||||
'gallery_guests.name as guest_name',
|
||||
'gallery_guests.email as guest_email'
|
||||
)
|
||||
.orderBy('guest_invites.created_at', 'desc');
|
||||
|
||||
const invites = rows.map((r) => ({
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
|
||||
created_at: r.created_at,
|
||||
redeemed_at: r.redeemed_at,
|
||||
revoked_at: r.revoked_at,
|
||||
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
|
||||
guest: {
|
||||
id: r.guest_id,
|
||||
name: r.guest_name,
|
||||
email: r.guest_email,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
logger.error('Error listing invites:', error);
|
||||
res.status(500).json({ error: 'Failed to list invites' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/invites — create guest + invite
|
||||
// Body: { name, email? }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const inviteToken = crypto.randomBytes(24).toString('hex');
|
||||
|
||||
let guestId;
|
||||
let inviteId;
|
||||
await db.transaction(async (trx) => {
|
||||
const [guestRow] = await trx('gallery_guests')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
})
|
||||
.returning(['id']);
|
||||
guestId = guestRow.id;
|
||||
|
||||
const [inviteRow] = await trx('guest_invites')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
guest_id: guestId,
|
||||
token: inviteToken,
|
||||
created_by_admin_id: req.admin.id,
|
||||
})
|
||||
.returning(['id']);
|
||||
inviteId = inviteRow.id;
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_created',
|
||||
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
res.json({
|
||||
invite: {
|
||||
id: inviteId,
|
||||
token: inviteToken,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
|
||||
status: 'pending',
|
||||
guest: { id: guestId, name, email: email || null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating invite:', error);
|
||||
res.status(500).json({ error: 'Failed to create invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/invites/:inviteId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, inviteId } = req.params;
|
||||
const updated = await db('guest_invites')
|
||||
.where({ id: inviteId, event_id: eventId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Invite not found or already revoked' });
|
||||
}
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_revoked',
|
||||
{ event_id: eventId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error revoking invite:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
|
||||
// Query: format=txt|csv|json (default: csv)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/export-all',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
|
||||
|
||||
const guests = await db('gallery_guests')
|
||||
.where({ event_id: eventId, is_deleted: false })
|
||||
.select('id', 'name', 'email');
|
||||
|
||||
if (guests.length === 0) {
|
||||
return res.status(404).json({ error: 'No guests to export' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="event-${eventId}-guests.zip"`
|
||||
);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Archive error:', err);
|
||||
res.status(500).end();
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
for (const g of guests) {
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', g.id)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
let body;
|
||||
if (format === 'json') {
|
||||
body = JSON.stringify({ guest: g, selections }, null, 2);
|
||||
} else if (format === 'csv') {
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
body = [header, ...rows].join('\n');
|
||||
} else {
|
||||
// txt — just filenames
|
||||
body = selections.map((s) => s.original_filename || s.filename).join('\n');
|
||||
}
|
||||
archive.append(body, { name: filename });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
logger.error('Error exporting all guests:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to export guests' });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
|
||||
// (Phase 2)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const feedback = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.select(
|
||||
'photo_feedback.id as feedback_id',
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.created_at',
|
||||
'photos.id as photo_id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.type'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
const photoFor = (row) => ({
|
||||
id: row.photo_id,
|
||||
filename: row.filename,
|
||||
original_filename: row.original_filename,
|
||||
type: row.type,
|
||||
url: `/api/admin/photos/${eventId}/photo/${row.photo_id}`,
|
||||
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
|
||||
});
|
||||
|
||||
const selections = {
|
||||
liked: [],
|
||||
favorited: [],
|
||||
rated: [],
|
||||
commented: [],
|
||||
};
|
||||
for (const row of feedback) {
|
||||
if (row.feedback_type === 'like') {
|
||||
selections.liked.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'favorite') {
|
||||
selections.favorited.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'rating') {
|
||||
selections.rated.push({ photo: photoFor(row), rating: row.rating });
|
||||
} else if (row.feedback_type === 'comment') {
|
||||
selections.commented.push({
|
||||
photo: photoFor(row),
|
||||
comment: row.comment_text,
|
||||
created_at: row.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
guest: {
|
||||
...serializeGuest(guest),
|
||||
stats: {
|
||||
likes: selections.liked.length,
|
||||
favorites: selections.favorited.length,
|
||||
comments: selections.commented.length,
|
||||
ratings: selections.rated.length,
|
||||
},
|
||||
},
|
||||
selections,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching guest detail:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch guest detail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
|
||||
// Query: format=txt|csv|json
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
if (format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
|
||||
}
|
||||
if (format === 'csv') {
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
return res.send([header, ...rows].join('\n'));
|
||||
}
|
||||
// txt — one filename per line
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
|
||||
} catch (error) {
|
||||
logger.error('Error exporting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to export guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const result = await feedbackService.anonymizeGuestFeedback(guestId);
|
||||
|
||||
await db('gallery_guests').where({ id: guestId }).update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_deleted',
|
||||
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to delete guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
|
||||
// Body: { mergeIds: number[] }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/:keepId/merge',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, keepId } = req.params;
|
||||
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
|
||||
|
||||
if (mergeIds.length === 0) {
|
||||
return res.status(400).json({ error: 'mergeIds is required' });
|
||||
}
|
||||
if (mergeIds.includes(Number(keepId))) {
|
||||
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
|
||||
}
|
||||
|
||||
// Sanity check: all guests belong to this event.
|
||||
const all = await db('gallery_guests')
|
||||
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
|
||||
.where({ event_id: eventId });
|
||||
if (all.length !== mergeIds.length + 1) {
|
||||
return res.status(400).json({ error: 'All guests must belong to the same event' });
|
||||
}
|
||||
|
||||
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
|
||||
|
||||
// Soft-delete the merged (source) guests.
|
||||
await db('gallery_guests')
|
||||
.whereIn('id', mergeIds.map(Number))
|
||||
.update({ is_deleted: true, last_seen_at: db.fn.now() });
|
||||
|
||||
await logActivity(
|
||||
'guest_merged',
|
||||
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error merging guests:', error);
|
||||
res.status(500).json({ error: 'Failed to merge guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,11 +3,10 @@ const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
this.whereNull('photos.width').orWhereNull('photos.height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
.select(
|
||||
'photos.id', 'photos.path', 'photos.filename',
|
||||
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
|
||||
'events.source_mode', 'events.external_path', 'events.slug'
|
||||
);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
||||
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (err) {
|
||||
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
updated_at: db.fn.now()
|
||||
height: metadata.height
|
||||
});
|
||||
successCount++;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploa
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -120,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Upload photos for an event
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
@@ -170,7 +171,30 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
}
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
|
||||
// Enforce photo cap if set
|
||||
if (event.photo_cap && event.photo_cap > 0) {
|
||||
const existingPhotoCount = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.count('id as count')
|
||||
.first();
|
||||
const currentCount = parseInt(existingPhotoCount.count) || 0;
|
||||
const newFilesCount = (req.files && req.files.length) || 0;
|
||||
if (currentCount + newFilesCount > event.photo_cap) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
@@ -499,7 +523,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
});
|
||||
|
||||
// Delete a photo
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -561,10 +585,10 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
});
|
||||
|
||||
// Update a photo (e.g., change category)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
const { category_id, visibility } = req.body;
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
@@ -578,6 +602,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(visibility)) {
|
||||
updateData.visibility = visibility;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
@@ -617,7 +648,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
});
|
||||
|
||||
// Bulk delete photos
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds } = req.body;
|
||||
@@ -690,7 +721,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
});
|
||||
|
||||
// Bulk update photos
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
@@ -713,6 +744,13 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (updates.visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(updates.visibility)) {
|
||||
updateData.visibility = updates.visibility;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
@@ -746,7 +784,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
});
|
||||
|
||||
// Download a photo
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -778,17 +816,18 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
});
|
||||
|
||||
// Get all photos for an event
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, search, sort = 'date' } = req.query;
|
||||
const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating } = req.query;
|
||||
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
|
||||
|
||||
const logic = req.query.logic === 'OR' ? 'OR' : 'AND';
|
||||
|
||||
let query = db('photos')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.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 category_id
|
||||
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
@@ -805,18 +844,53 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Keep type filter for backwards compatibility
|
||||
if (type) {
|
||||
query = query.where({ 'photos.type': type });
|
||||
}
|
||||
|
||||
|
||||
// Search by filename
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
query = query.where('photos.filename', 'like', `%${escapedSearch}%`);
|
||||
}
|
||||
|
||||
|
||||
// Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic
|
||||
const feedbackConditions = [];
|
||||
if (has_likes === 'true' || has_likes === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.like_count', '>', 0));
|
||||
}
|
||||
if (has_favorites === 'true' || has_favorites === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.favorite_count', '>', 0));
|
||||
}
|
||||
if (has_comments === 'true' || has_comments === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.comment_count', '>', 0));
|
||||
}
|
||||
if (min_rating !== undefined && min_rating !== null && min_rating !== '') {
|
||||
const minRatingNum = parseFloat(min_rating);
|
||||
if (!isNaN(minRatingNum)) {
|
||||
feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum));
|
||||
}
|
||||
}
|
||||
if (feedbackConditions.length > 0) {
|
||||
if (logic === 'OR') {
|
||||
query = query.where(builder => {
|
||||
feedbackConditions.forEach((cond, idx) => {
|
||||
if (idx === 0) {
|
||||
cond(builder);
|
||||
} else {
|
||||
builder.orWhere(sub => cond(sub));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
feedbackConditions.forEach(cond => {
|
||||
query = query.where(builder => cond(builder));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let orderByColumn = 'photos.uploaded_at';
|
||||
if (sort === 'name') {
|
||||
@@ -848,9 +922,9 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename || null,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
url: `/api/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
@@ -877,7 +951,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
});
|
||||
|
||||
// Serve photo with admin authentication
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -914,7 +988,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
});
|
||||
|
||||
// Serve thumbnail with admin authentication
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -954,7 +1028,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -980,7 +1054,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async
|
||||
// ============================================
|
||||
|
||||
// Initialize a chunked upload
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
@@ -1018,7 +1092,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
});
|
||||
|
||||
// Upload a chunk
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
@@ -1039,7 +1113,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
});
|
||||
|
||||
// Complete chunked upload and process the file
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, uploadId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -1081,7 +1155,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
});
|
||||
|
||||
// Get upload status
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
@@ -1099,7 +1173,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
|
||||
});
|
||||
|
||||
// Abort chunked upload
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
|
||||
@@ -114,7 +114,8 @@ router.post('/invite', [
|
||||
const invitation = await userManagementService.createInvitation({
|
||||
email: req.body.email,
|
||||
roleId: req.body.role_id,
|
||||
invitedById: req.admin.id
|
||||
invitedById: req.admin.id,
|
||||
inviterRoleName: req.admin.roleName
|
||||
});
|
||||
|
||||
successResponse(res, { invitation }, 201);
|
||||
@@ -146,7 +147,12 @@ router.get('/:id', [
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
|
||||
const targetId = parseInt(req.params.id);
|
||||
// Non-super_admin users can only view their own profile
|
||||
if (req.admin.roleName !== 'super_admin' && targetId !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
const user = await userManagementService.getAdminUserById(targetId);
|
||||
res.json({ user: transformUser(user) });
|
||||
}));
|
||||
|
||||
@@ -169,7 +175,8 @@ router.put('/:id', [
|
||||
const user = await userManagementService.updateAdminUser(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.admin.id
|
||||
req.admin.id,
|
||||
{ roleName: req.admin.roleName }
|
||||
);
|
||||
|
||||
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
|
||||
|
||||
+112
-8
@@ -13,6 +13,7 @@ const {
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
@@ -117,9 +118,8 @@ router.post('/admin/login', [
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Include role in response
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
@@ -145,7 +145,8 @@ router.post('/logout', async (req, res) => {
|
||||
const token = adminToken || galleryToken;
|
||||
|
||||
if (token) {
|
||||
// End the session
|
||||
// Revoke the token so it can't be reused, then end the session
|
||||
await revokeToken(token, 'user_logout');
|
||||
endSession(token);
|
||||
|
||||
try {
|
||||
@@ -198,6 +199,8 @@ router.post('/gallery/verify', [
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
||||
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
@@ -280,7 +283,8 @@ router.post('/gallery/verify', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -289,6 +293,82 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
|
||||
// Client access login (PIN-based)
|
||||
router.post('/gallery/:slug/client-login', [
|
||||
body('password').notEmpty().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
const { password } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.client_password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
accessLevel: 'client',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: true
|
||||
},
|
||||
accessLevel: 'client'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Client login error:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Share link authentication (token-based)
|
||||
router.post('/gallery/share-login', [
|
||||
body('slug').notEmpty().trim(),
|
||||
@@ -304,6 +384,17 @@ router.post('/gallery/share-login', [
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Rate limit share-link login attempts
|
||||
const shareIdentifier = `gallery:${slug}:share`;
|
||||
const lockoutStatus = await checkAccountLockout(shareIdentifier, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Share link login attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
@@ -316,12 +407,14 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
@@ -353,7 +446,8 @@ router.post('/gallery/share-login', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -362,10 +456,14 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery logout to clear cookies
|
||||
// Gallery logout to clear cookies and revoke token
|
||||
router.post('/gallery/logout', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.body || {};
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (token) {
|
||||
await revokeToken(token, 'gallery_logout');
|
||||
}
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
@@ -386,11 +484,17 @@ router.get('/session', async (req, res) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
|
||||
// Check if token has been revoked (e.g. after logout)
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
|
||||
+131
-18
@@ -6,7 +6,7 @@ const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
@@ -74,7 +74,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
@@ -122,7 +122,9 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'hero_logo_url',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor'
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
'default_photo_sort'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -138,11 +140,16 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
|
||||
// Check if event is archived
|
||||
if (event.is_archived) {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !isAdminPreview(req)) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
@@ -176,7 +183,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center'
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -198,10 +206,18 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Build the query with sorting
|
||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||
const isClient = req.accessLevel === 'client';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select('photos.*');
|
||||
|
||||
// Guests only see visible photos; clients see all
|
||||
if (!isClient) {
|
||||
photosQuery = photosQuery.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
@@ -295,6 +311,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if feedback should be visible to guests
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
const showFeedbackToGuests = isClient || feedbackSettings.show_feedback_to_guests !== false;
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -383,6 +404,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
header_style: req.event.header_style || 'standard',
|
||||
hero_divider_style: req.event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
@@ -414,12 +436,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
// EXIF capture date
|
||||
captured_at: photo.captured_at || null,
|
||||
// Media type
|
||||
media_type: photo.media_type || null,
|
||||
mime_type: photo.mime_type || null,
|
||||
duration: photo.duration || null,
|
||||
// Feedback data (hidden when show_feedback_to_guests is disabled)
|
||||
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
|
||||
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
|
||||
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
|
||||
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
|
||||
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
|
||||
// Visibility (only included for clients)
|
||||
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -429,24 +459,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoId } = req.params;
|
||||
const { visibility } = req.body;
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoIds, visibility } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const count = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', req.event.id)
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error bulk updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download single photo
|
||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -745,11 +842,15 @@ router.get('/:slug/photo/:photoId',
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
@@ -935,6 +1036,11 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
@@ -1013,6 +1119,11 @@ router.get('/:slug/hero/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video - videos don't get hero images
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
@@ -1093,10 +1204,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
res.json({
|
||||
feedback_enabled: settings.feedback_enabled || false,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests,
|
||||
require_name_email: settings.require_name_email || false,
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const { photoAuth } = require('../middleware/photoAuth');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackModeration = require('../services/feedbackModeration');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
|
||||
try {
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
// Only send relevant settings to guests
|
||||
// Convert SQLite boolean values (0/1) to proper booleans
|
||||
const guestSettings = {
|
||||
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
require_name_email: Boolean(settings.require_name_email),
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
};
|
||||
|
||||
|
||||
res.json(guestSettings);
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
|
||||
// Get feedback for a specific photo
|
||||
router.get('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
// Submit feedback for a photo
|
||||
router.post('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
validateFeedbackSubmission,
|
||||
checkValidation,
|
||||
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Get feedback settings
|
||||
|
||||
// Get feedback settings first so we can enforce identity_mode.
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
|
||||
}
|
||||
|
||||
|
||||
// In guest identity mode, a valid guest token is required. The server
|
||||
// never trusts guest_name/guest_email from the body in this mode — it
|
||||
// reads them from the verified token via req.guest.
|
||||
if (settings.identity_mode === 'guest') {
|
||||
if (!req.guest || req.guest.eventId !== event.id) {
|
||||
return res.status(401).json({
|
||||
error: 'Guest identity required',
|
||||
code: 'GUEST_IDENTITY_REQUIRED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Check if specific feedback type is allowed
|
||||
const feedbackType = req.body.feedback_type;
|
||||
const typeAllowed = {
|
||||
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
comment: settings.allow_comments,
|
||||
favorite: settings.allow_favorites
|
||||
};
|
||||
|
||||
|
||||
if (!typeAllowed[feedbackType]) {
|
||||
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
|
||||
}
|
||||
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Validate guest requirements
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
|
||||
// Validate guest requirements only in simple mode. In guest mode, the
|
||||
// identity is already provided via the token and verified above.
|
||||
if (settings.identity_mode !== 'guest') {
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply rate limiting based on feedback type
|
||||
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -192,19 +212,21 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// If we got here and response was sent (rate limited), return
|
||||
if (res.headersSent) return;
|
||||
|
||||
// Prepare feedback data
|
||||
|
||||
// Prepare feedback data. In guest mode, use the verified token as the
|
||||
// source of truth for name/email — never the body.
|
||||
const feedbackData = {
|
||||
feedback_type: feedbackType,
|
||||
rating: req.body.rating,
|
||||
comment_text: req.body.comment_text,
|
||||
guest_name: req.body.guest_name,
|
||||
guest_email: req.body.guest_email,
|
||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||
guest_id: req.guest?.id ?? null,
|
||||
ip_address: req.ip || req.connection.remoteAddress,
|
||||
user_agent: req.headers['user-agent'],
|
||||
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
|
||||
moderate_comments: settings.moderate_comments
|
||||
};
|
||||
|
||||
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
|
||||
// Get user's own feedback for all photos
|
||||
router.get('/:slug/my-feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
const myFeedback = await db('photo_feedback')
|
||||
|
||||
const query = db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.event_id', event.id)
|
||||
.where('photo_feedback.guest_identifier', guestIdentifier)
|
||||
.where('photo_feedback.event_id', event.id);
|
||||
|
||||
// Prefer guest_id lookup when a verified guest token is present
|
||||
// (per-person identity). Fall back to the device hash otherwise.
|
||||
if (req.guest?.id) {
|
||||
query.where('photo_feedback.guest_id', req.guest.id);
|
||||
} else {
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
query.where('photo_feedback.guest_identifier', guestIdentifier);
|
||||
}
|
||||
|
||||
const myFeedback = await query
|
||||
.select(
|
||||
'photo_feedback.*',
|
||||
'photos.filename',
|
||||
'photos.path'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
|
||||
res.json(myFeedback);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user feedback:', error);
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const guestRecovery = require('../services/guestRecoveryService');
|
||||
|
||||
const MAX_NAME_LEN = 100;
|
||||
const MAX_EMAIL_LEN = 255;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// In-memory rate limit for guest registration (20 per hour per IP). Simple
|
||||
// sliding window; on process restart the counters reset which is acceptable.
|
||||
const registrationAttempts = new Map();
|
||||
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
|
||||
const REGISTRATION_MAX = 20;
|
||||
|
||||
function checkRegistrationRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
registrationAttempts.set(ip, entry);
|
||||
return entry.count <= REGISTRATION_MAX;
|
||||
}
|
||||
|
||||
function sanitizeName(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
// Strip HTML/control chars, collapse whitespace.
|
||||
const cleaned = value
|
||||
.replace(/[<>&"']/g, '')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return cleaned.slice(0, MAX_NAME_LEN);
|
||||
}
|
||||
|
||||
function sanitizeEmail(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest
|
||||
* Body: { name, email? }
|
||||
*
|
||||
* Registers a new per-person guest identity for this gallery. Returns a JWT
|
||||
* that the frontend must send as the x-guest-token header on subsequent
|
||||
* feedback requests.
|
||||
*/
|
||||
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
if (!checkRegistrationRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many registration attempts' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
// Guest registration is only meaningful when feedback is enabled.
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
|
||||
}
|
||||
|
||||
const name = sanitizeName(req.body?.name);
|
||||
if (!name || name.length < 1) {
|
||||
return res.status(400).json({ error: 'Name is required', field: 'name' });
|
||||
}
|
||||
|
||||
let email = sanitizeEmail(req.body?.email);
|
||||
if (email && !EMAIL_REGEX.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
|
||||
}
|
||||
if (settings.require_name_email && !email) {
|
||||
return res.status(400).json({ error: 'Email is required', field: 'email' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
|
||||
|
||||
const [row] = await db('gallery_guests')
|
||||
.insert({
|
||||
event_id: event.id,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
ip_address_last: ip.substring(0, 45),
|
||||
user_agent_last: userAgent,
|
||||
})
|
||||
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: row.id,
|
||||
eventId: event.id,
|
||||
identifier: row.identifier,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
logger.info('Guest registered', {
|
||||
eventId: event.id,
|
||||
guestId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
identifier: row.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest registration failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to register guest' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /gallery/:slug/guest/me
|
||||
* Returns the current guest profile from a valid guest token. 401 otherwise.
|
||||
*/
|
||||
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
// Update last_seen_at on each profile fetch (cheap and useful for admin).
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: req.guest.id,
|
||||
name: req.guest.name,
|
||||
email: req.guest.email,
|
||||
identifier: req.guest.identifier,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest profile fetch failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to fetch guest profile' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /gallery/:slug/guest/me
|
||||
*
|
||||
* "Forget me" — soft-deletes the guest row and anonymizes their feedback so
|
||||
* aggregate counts remain stable but personal data is removed.
|
||||
*/
|
||||
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
await feedbackService.anonymizeGuestFeedback(req.guest.id);
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
logger.info('Guest self-forgot', {
|
||||
eventId: req.event.id,
|
||||
guestId: req.guest.id,
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest forget-me failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to forget guest' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.2 — Email-based identity recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
|
||||
const recoveryAttempts = new Map();
|
||||
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFY_MAX = 20;
|
||||
function checkRecoveryRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
recoveryAttempts.set(ip, entry);
|
||||
return entry.count <= VERIFY_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/recover
|
||||
* Body: { email }
|
||||
*
|
||||
* Sends a 6-digit code to the email if it matches an existing guest. Returns
|
||||
* 200 regardless of whether a matching guest exists (prevents enumeration).
|
||||
*/
|
||||
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many recovery attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
// Still return 200 to avoid leaking validity of the email field.
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (guest) {
|
||||
try {
|
||||
const code = await guestRecovery.createCode(event.id, email);
|
||||
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
|
||||
} catch (sendError) {
|
||||
logger.error('Failed to send recovery email', { error: sendError.message });
|
||||
// Still return 200 so clients can't distinguish failures.
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest recovery request failed', { error: error.message });
|
||||
return res.json({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/verify
|
||||
* Body: { email, code }
|
||||
*
|
||||
* Exchanges a valid verification code for a guest token. Reuses the existing
|
||||
* guest row associated with the email (the guest continues where they left
|
||||
* off, cross-device).
|
||||
*/
|
||||
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many verification attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
const code = String(req.body?.code || '').trim();
|
||||
if (!email || !code) {
|
||||
return res.status(400).json({ error: 'Email and code are required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
|
||||
if (!verifyResult.ok) {
|
||||
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
email_verified_at: guest.email_verified_at || db.fn.now(),
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
});
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: guest.id,
|
||||
eventId: event.id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
});
|
||||
|
||||
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: guest.id,
|
||||
name: guest.name,
|
||||
email: guest.email,
|
||||
identifier: guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest verify failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to verify code' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.3 — Invite token redemption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/redeem
|
||||
* Body: { inviteToken }
|
||||
*
|
||||
* Redeems a pre-minted invite token (created by admin). Single use.
|
||||
*/
|
||||
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const inviteToken = String(req.body?.inviteToken || '').trim();
|
||||
if (!inviteToken) {
|
||||
return res.status(400).json({ error: 'Invite token required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const invite = await trx('guest_invites')
|
||||
.where({ token: inviteToken, event_id: event.id })
|
||||
.first();
|
||||
if (!invite) return { error: 'not_found' };
|
||||
if (invite.revoked_at) return { error: 'revoked' };
|
||||
if (invite.redeemed_at) return { error: 'already_redeemed' };
|
||||
|
||||
const guest = await trx('gallery_guests')
|
||||
.where({ id: invite.guest_id, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) return { error: 'guest_missing' };
|
||||
|
||||
await trx('guest_invites')
|
||||
.where({ id: invite.id })
|
||||
.update({ redeemed_at: trx.fn.now() });
|
||||
|
||||
await trx('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
last_seen_at: trx.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return { guest };
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const statusMap = {
|
||||
not_found: 404,
|
||||
revoked: 410,
|
||||
already_redeemed: 409,
|
||||
guest_missing: 404,
|
||||
};
|
||||
return res.status(statusMap[result.error] || 400).json({ error: result.error });
|
||||
}
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: result.guest.id,
|
||||
eventId: event.id,
|
||||
identifier: result.guest.identifier,
|
||||
name: result.guest.name,
|
||||
});
|
||||
|
||||
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: result.guest.id,
|
||||
name: result.guest.name,
|
||||
email: result.guest.email,
|
||||
identifier: result.guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Invite redemption failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to redeem invite' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -350,34 +350,11 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
/**
|
||||
* Get security statistics for monitoring
|
||||
*/
|
||||
router.get('/security/stats', async (req, res) => {
|
||||
try {
|
||||
// Only allow admin access
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
const admin = await db('admin_users').where({ id: decoded.id }).first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
// Get security statistics
|
||||
const stats = {
|
||||
|
||||
@@ -437,26 +437,59 @@ async function performLocalBackup(config, files) {
|
||||
};
|
||||
}
|
||||
|
||||
function validateRsyncParam(value, label) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${label}: contains disallowed characters`);
|
||||
}
|
||||
if (value.length > 1024) {
|
||||
throw new Error(`Invalid ${label}: too long`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildRsyncArgs(config) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
||||
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
||||
|
||||
if (!host || !remotePath) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
throw new Error('Invalid rsync host format');
|
||||
}
|
||||
|
||||
const args = ['-avz', '--delete', '--stats'];
|
||||
if (config.backup_rsync_ssh_key) {
|
||||
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
|
||||
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
|
||||
throw new Error('SSH key file not found or is not a file');
|
||||
}
|
||||
// Pass SSH options as separate array elements to avoid shell interpretation
|
||||
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
||||
}
|
||||
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = config.backup_rsync_user
|
||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||
|
||||
const user = config.backup_rsync_user;
|
||||
if (user) {
|
||||
validateRsyncParam(user, 'user');
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
throw new Error('Invalid rsync username format');
|
||||
}
|
||||
}
|
||||
|
||||
const destination = user
|
||||
? `${user}@${host}:${remotePath}`
|
||||
: `${host}:${remotePath}`;
|
||||
|
||||
args.push(source, destination);
|
||||
|
||||
@@ -2,6 +2,7 @@ const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
@@ -99,113 +100,74 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
// Fourth priority: Check if the email domain suggests a language
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
const domainLanguageMap = [
|
||||
{ domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
|
||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||
];
|
||||
for (const { domains, language: lang } of domainLanguageMap) {
|
||||
if (domains.some(d => domain.endsWith(d))) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Fall back to non-language-specific fields for backward compatibility
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
// Darken a hex color by a percentage (0-1)
|
||||
function darkenColor(hex, amount = 0.15) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
|
||||
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
|
||||
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
|
||||
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
// Get branding settings for logo and email colors
|
||||
let logoUrl = '';
|
||||
let companyName = 'PicPeak';
|
||||
let primaryColor = '#5C8762';
|
||||
let secondaryColor = '#f9f9f9';
|
||||
try {
|
||||
const brandingSettings = await db('app_settings')
|
||||
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_url', 'branding_company_name',
|
||||
'email_primary_color', 'email_secondary_color'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
|
||||
brandingSettings.forEach(setting => {
|
||||
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
|
||||
try {
|
||||
logoUrl = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
logoUrl = setting.setting_value;
|
||||
}
|
||||
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
|
||||
try {
|
||||
companyName = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
companyName = setting.setting_value;
|
||||
}
|
||||
const val = setting.setting_value;
|
||||
if (setting.setting_key === 'branding_logo_url' && val) {
|
||||
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
|
||||
} else if (setting.setting_key === 'branding_company_name' && val) {
|
||||
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
|
||||
} else if (setting.setting_key === 'email_primary_color' && val) {
|
||||
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
|
||||
} else if (setting.setting_key === 'email_secondary_color' && val) {
|
||||
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching branding settings:', error);
|
||||
}
|
||||
|
||||
// If no custom logo, use default PicPeak logo
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||
const hoverColor = darkenColor(primaryColor, 0.15);
|
||||
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
// Build full logo URL - ensure logoUrl is a valid non-empty string
|
||||
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||
const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png';
|
||||
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
|
||||
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
<head>
|
||||
@@ -233,7 +195,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -246,7 +208,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.email-content h2 {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
@@ -267,7 +229,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
@@ -275,10 +237,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4a6f4f;
|
||||
background-color: ${hoverColor};
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f9f9f9;
|
||||
background-color: ${secondaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
@@ -295,11 +257,11 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
margin: 5px 0;
|
||||
}
|
||||
a {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #4a6f4f;
|
||||
color: ${hoverColor};
|
||||
}
|
||||
strong {
|
||||
color: #333;
|
||||
@@ -338,6 +300,168 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get translation from email_template_translations table with fallback chain
|
||||
let subject = '';
|
||||
let htmlBody = '';
|
||||
let textBody = '';
|
||||
|
||||
try {
|
||||
// Try requested language first, then English, then any available
|
||||
let translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (!translation) {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlBody = translation.body_html || '';
|
||||
textBody = translation.body_text || '';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
|
||||
}
|
||||
|
||||
// Fallback to legacy column-based fields if no translation found
|
||||
if (!subject && !htmlBody) {
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject_en || template.subject || '';
|
||||
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
|
||||
textBody = template[textField] || template.body_text_en || template.body_text || '';
|
||||
}
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
const passwordSecurityI18n = {
|
||||
en: '(Not shown for security reasons)',
|
||||
de: '(Aus Sicherheitsgründen nicht angezeigt)',
|
||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||
pt: '(Não exibido por motivos de segurança)',
|
||||
ru: '(Не показано в целях безопасности)',
|
||||
};
|
||||
const noPasswordI18n = {
|
||||
en: 'No password required',
|
||||
de: 'Kein Passwort erforderlich',
|
||||
nl: 'Geen wachtwoord vereist',
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Safe template replacement (no code execution, only simple variable substitution)
|
||||
function safeTemplateReplace(template, variables) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
variables.hasOwnProperty(key) ? String(variables[key]) : match
|
||||
);
|
||||
}
|
||||
|
||||
subject = safeTemplateReplace(subject, processedVariables);
|
||||
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
|
||||
textBody = safeTemplateReplace(textBody, processedVariables);
|
||||
|
||||
// Inject client access section if client_link is provided (#172)
|
||||
if (processedVariables.client_link) {
|
||||
const clientAccessI18n = {
|
||||
de: {
|
||||
label: 'Kundenzugang (Privat)',
|
||||
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
|
||||
link: 'Kundenzugang öffnen',
|
||||
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
|
||||
},
|
||||
ru: {
|
||||
label: 'Доступ клиента (Личный)',
|
||||
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
},
|
||||
nl: {
|
||||
label: 'Klanttoegang (Privé)',
|
||||
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
|
||||
link: 'Klanttoegang openen',
|
||||
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
link: 'Abrir Acesso do Cliente',
|
||||
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
|
||||
},
|
||||
en: {
|
||||
label: 'Client Access (Private)',
|
||||
desc: 'Review and manage photo visibility before sharing with guests:',
|
||||
link: 'Open Client Access',
|
||||
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
|
||||
},
|
||||
};
|
||||
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
|
||||
const clientAccessLabel = ci18n.label;
|
||||
const clientAccessDesc = ci18n.desc;
|
||||
const clientAccessLink = ci18n.link;
|
||||
const clientAccessWarning = ci18n.warning;
|
||||
const pinLabel = 'PIN';
|
||||
|
||||
htmlBody += `
|
||||
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="font-size: 15px;">🔒 ${clientAccessLabel}</strong>
|
||||
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
|
||||
<p style="margin: 8px 0;">
|
||||
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
|
||||
</p>
|
||||
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${clientAccessWarning}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
|
||||
|
||||
return { subject, htmlBody: styledHtmlBody, textBody };
|
||||
}
|
||||
@@ -558,5 +682,6 @@ module.exports = {
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
testEmailConnection,
|
||||
wrapEmailHtml
|
||||
};
|
||||
|
||||
@@ -138,7 +138,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests,
|
||||
// Upload settings
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
// Photo cap
|
||||
photo_cap
|
||||
} = eventData;
|
||||
|
||||
const requirePassword = parseBooleanInput(require_password, true);
|
||||
@@ -207,7 +209,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
|
||||
// Upload settings
|
||||
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
|
||||
upload_category_id: upload_category_id || null
|
||||
upload_category_id: upload_category_id || null,
|
||||
// Photo cap
|
||||
photo_cap: photo_cap || null
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
|
||||
@@ -23,10 +23,15 @@ class FeedbackService {
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true
|
||||
show_feedback_to_guests: true,
|
||||
identity_mode: 'simple'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Back-compat: rows created before migration 078 have NULL identity_mode.
|
||||
if (!settings.identity_mode) {
|
||||
settings.identity_mode = 'simple';
|
||||
}
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -73,23 +78,29 @@ class FeedbackService {
|
||||
*/
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
// Check if similar feedback already exists (prevent duplicates)
|
||||
// Check if similar feedback already exists (prevent duplicates).
|
||||
// When a per-person guest_id is present, scope the check to that guest
|
||||
// so two guests on the same device can independently like a photo.
|
||||
if (feedback_type !== 'comment') {
|
||||
const existing = await db('photo_feedback')
|
||||
const duplicateQuery = db('photo_feedback')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
guest_identifier: guestIdentifier
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (guest_id) {
|
||||
duplicateQuery.where('guest_id', guest_id);
|
||||
} else {
|
||||
duplicateQuery.where('guest_identifier', guestIdentifier);
|
||||
}
|
||||
const existing = await duplicateQuery.first();
|
||||
|
||||
if (existing) {
|
||||
if (feedback_type === 'rating' && rating !== existing.rating) {
|
||||
@@ -129,6 +140,7 @@ class FeedbackService {
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
guest_id: guest_id || null,
|
||||
ip_address,
|
||||
user_agent,
|
||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||
@@ -232,7 +244,7 @@ class FeedbackService {
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
|
||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -460,6 +472,75 @@ class FeedbackService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize feedback belonging to a guest — sets guest_id to NULL on all
|
||||
* their feedback rows and clears guest_name/guest_email for privacy, then
|
||||
* recomputes denormalized photo counts on affected photos.
|
||||
*
|
||||
* Used by self-service "forget me" and admin guest deletion.
|
||||
*/
|
||||
async anonymizeGuestFeedback(guestId) {
|
||||
try {
|
||||
const affected = await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.update({
|
||||
guest_id: null,
|
||||
guest_name: null,
|
||||
guest_email: null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { anonymized: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error anonymizing guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin
|
||||
* guest merge and email-based identity recovery when a user re-registers.
|
||||
* Recomputes denormalized counts on affected photos.
|
||||
*/
|
||||
async mergeGuestFeedback(keepGuestId, sourceGuestIds) {
|
||||
try {
|
||||
const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId);
|
||||
if (sources.length === 0) {
|
||||
return { merged: 0, photos: 0 };
|
||||
}
|
||||
|
||||
const affected = await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.update({
|
||||
guest_id: keepGuestId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { merged: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error merging guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackService();
|
||||
@@ -5,7 +5,7 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const { isVideoMimeType } = require('./videoProcessor');
|
||||
const mime = require('mime-types');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Guest identity recovery service (Phase 3.2).
|
||||
*
|
||||
* Sends a short-lived 6-digit verification code to a guest's email address
|
||||
* so they can re-link their identity across devices. The code is stored as
|
||||
* a bcrypt hash in `guest_verification_codes` with a 15-minute expiry.
|
||||
*
|
||||
* Uses the email transporter from emailProcessor — no new template row is
|
||||
* needed; the email body is built inline so this works out of the box.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
function generateCode() {
|
||||
// 6 digits, zero-padded.
|
||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
async function createCode(eventId, email) {
|
||||
const code = generateCode();
|
||||
const codeHash = await bcrypt.hash(code, 10);
|
||||
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
||||
|
||||
// Invalidate any previous unconsumed codes for this email+event.
|
||||
await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
await db('guest_verification_codes').insert({
|
||||
event_id: eventId,
|
||||
email: email.toLowerCase(),
|
||||
code_hash: codeHash,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
const transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
const config = await db('email_configs').first();
|
||||
if (!config) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
const subject = `Your verification code: ${code}`;
|
||||
const htmlBody = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px;">
|
||||
<h2>Welcome back to ${eventName}</h2>
|
||||
<p>Enter this code to recover your picks in the gallery:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px; margin: 20px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
This code expires in 15 minutes. If you did not request it, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
to: toEmail,
|
||||
subject,
|
||||
html: styledHtml,
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`,
|
||||
});
|
||||
|
||||
logger.info('Guest recovery code sent', { email: toEmail });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a code. Returns true if valid + marks it consumed.
|
||||
* Increments attempts on failure. Rejects after MAX_ATTEMPTS.
|
||||
*/
|
||||
async function verifyCode(eventId, email, submittedCode) {
|
||||
const normalized = String(submittedCode || '').trim();
|
||||
if (!/^\d{6}$/.test(normalized)) {
|
||||
return { ok: false, reason: 'invalid_format' };
|
||||
}
|
||||
|
||||
const row = await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.andWhere('expires_at', '>', new Date())
|
||||
.orderBy('created_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'expired_or_missing' };
|
||||
}
|
||||
|
||||
if (row.attempts >= MAX_ATTEMPTS) {
|
||||
await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() });
|
||||
return { ok: false, reason: 'too_many_attempts' };
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(normalized, row.code_hash);
|
||||
if (!matches) {
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ attempts: row.attempts + 1 });
|
||||
return { ok: false, reason: 'wrong_code' };
|
||||
}
|
||||
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCode,
|
||||
sendRecoveryEmail,
|
||||
verifyCode,
|
||||
CODE_TTL_MS,
|
||||
MAX_ATTEMPTS,
|
||||
};
|
||||
@@ -163,22 +163,13 @@ async function createRateLimiter() {
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async (req) => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
@@ -223,22 +214,13 @@ async function createAuthRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: config.authMaxRequests,
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async () => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
|
||||
@@ -30,10 +30,10 @@ async function verifyRecaptcha(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no secret key configured, log warning but pass
|
||||
// If no secret key configured, fail closed
|
||||
if (!secretKey) {
|
||||
console.warn('reCAPTCHA enabled but no secret key configured');
|
||||
return true;
|
||||
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
const SETTING_KEY = 'general_short_gallery_urls';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
@@ -87,7 +88,7 @@ const buildShareLinkVariants = async ({ slug, shareToken }) => {
|
||||
|
||||
const shortEnabled = await isShortGalleryUrlsEnabled();
|
||||
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const frontendBase = await getFrontendBaseUrl();
|
||||
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
|
||||
return {
|
||||
@@ -112,7 +113,8 @@ const getEventShareToken = (event) => {
|
||||
|
||||
const ACTIVE_EVENT_FILTER = {
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier) => {
|
||||
|
||||
@@ -85,6 +85,16 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal S3 endpoints in production
|
||||
// Local endpoints (e.g. MinIO on localhost) are allowed in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const { validateExternalUrl } = require('../../utils/networkValidation');
|
||||
const urlCheck = validateExternalUrl(endpoint);
|
||||
if (!urlCheck.valid) {
|
||||
throw new Error(`Invalid S3 endpoint: ${urlCheck.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
s3Config.endpoint = endpoint;
|
||||
|
||||
// For S3-compatible services with custom endpoints, force path style
|
||||
|
||||
@@ -18,7 +18,7 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
|
||||
* @param {object} params - { email, roleId, invitedById }
|
||||
* @returns {Promise<object>} Created invitation details
|
||||
*/
|
||||
async function createInvitation({ email, roleId, invitedById }) {
|
||||
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
|
||||
// Check if email already exists
|
||||
const existingUser = await db('admin_users').where('email', email).first();
|
||||
if (existingUser) {
|
||||
@@ -42,6 +42,11 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
throw new NotFoundError('Role', roleId);
|
||||
}
|
||||
|
||||
// Role hierarchy: only super_admin can invite super_admin
|
||||
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
|
||||
throw new ValidationError('Only Super Admins can invite new Super Admins');
|
||||
}
|
||||
|
||||
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
@@ -213,7 +218,7 @@ async function getAdminUserById(id) {
|
||||
* @param {number} updatedById - ID of user making the update
|
||||
* @returns {Promise<object>} Updated user
|
||||
*/
|
||||
async function updateAdminUser(id, updates, updatedById) {
|
||||
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
@@ -248,6 +253,34 @@ async function updateAdminUser(id, updates, updatedById) {
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', updates.role_id);
|
||||
}
|
||||
|
||||
// Role hierarchy enforcement
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
|
||||
|
||||
// Only super_admin can assign super_admin role
|
||||
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
|
||||
throw new ValidationError('Only Super Admins can assign the Super Admin role');
|
||||
}
|
||||
|
||||
// Prevent self-role-update
|
||||
if (id === updatedById) {
|
||||
throw new ValidationError('Cannot change your own role');
|
||||
}
|
||||
|
||||
// Prevent downgrading the last super_admin
|
||||
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
|
||||
const superAdminCount = await db('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (Number(superAdminCount?.count) <= 1) {
|
||||
throw new ValidationError('Cannot demote the last Super Admin');
|
||||
}
|
||||
}
|
||||
|
||||
allowedUpdates.role_id = updates.role_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ async function checkAccountLockout(identifier, ipAddress) {
|
||||
return { isLocked: false };
|
||||
} catch (error) {
|
||||
logger.error('Error checking account lockout:', error);
|
||||
return { isLocked: false }; // Fail open to avoid locking users out due to errors
|
||||
return { isLocked: true, remainingTime: 300 }; // Fail closed on DB error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ const FORBIDDEN_PATTERNS = [
|
||||
/on\w+\s*=/gi, // onclick=, onload=, etc.
|
||||
];
|
||||
|
||||
// Pattern for external URLs (block external, allow data: for images)
|
||||
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
|
||||
// Pattern for external URLs (block external, allow only safe raster data: images)
|
||||
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image\/(?:jpeg|jpg|png|gif|webp))/gi;
|
||||
|
||||
// Maximum CSS size in bytes (100KB)
|
||||
const MAX_CSS_SIZE = 100 * 1024;
|
||||
@@ -50,7 +50,8 @@ function sanitizeCss(css) {
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"]?)\s*data:image\/svg\+xml[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
|
||||
@@ -54,6 +54,8 @@ async function formatDate(date, language = 'en') {
|
||||
let locale = dateConfig.locale || 'en-GB';
|
||||
if (language === 'de') {
|
||||
locale = 'de-DE';
|
||||
} else if (language === 'pt') {
|
||||
locale = 'pt-BR';
|
||||
} else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') {
|
||||
locale = 'en-US';
|
||||
}
|
||||
|
||||
@@ -184,7 +184,9 @@ const validateFeedbackSettings = [
|
||||
body('allow_favorites').optional().isBoolean(),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean()
|
||||
body('show_feedback_to_guests').optional().isBoolean(),
|
||||
body('identity_mode').optional().isIn(['simple', 'guest'])
|
||||
.withMessage('identity_mode must be "simple" or "guest"')
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const getFrontendBaseUrl = async () => {
|
||||
let base = (process.env.FRONTEND_URL || '').trim().replace(/\/$/, '');
|
||||
if (base) return base;
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where('setting_key', 'general_site_url')
|
||||
.select('setting_value')
|
||||
.first();
|
||||
|
||||
if (setting && setting.setting_value) {
|
||||
let val = setting.setting_value;
|
||||
if (typeof val === 'string') {
|
||||
try { val = JSON.parse(val); } catch (_) {}
|
||||
}
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
base = val.trim().replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
module.exports = { getFrontendBaseUrl };
|
||||
@@ -0,0 +1,97 @@
|
||||
const { URL } = require('url');
|
||||
const net = require('net');
|
||||
|
||||
/**
|
||||
* Check if a hostname or IP resolves to a private/internal network address.
|
||||
* Blocks SSRF attempts targeting internal infrastructure.
|
||||
*/
|
||||
function isPrivateIP(hostname) {
|
||||
if (!hostname || typeof hostname !== 'string') return true;
|
||||
|
||||
const lower = hostname.toLowerCase().trim();
|
||||
|
||||
// Block known metadata / loopback hostnames
|
||||
const blockedHostnames = [
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.google',
|
||||
'169.254.169.254',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'[::1]',
|
||||
];
|
||||
if (blockedHostnames.includes(lower)) return true;
|
||||
|
||||
// If it's an IP address, check ranges directly
|
||||
if (net.isIPv4(lower)) {
|
||||
return isPrivateIPv4(lower);
|
||||
}
|
||||
|
||||
// IPv6 checks
|
||||
if (net.isIPv6(lower) || lower.startsWith('[')) {
|
||||
const cleanIp = lower.replace(/^\[|\]$/g, '');
|
||||
return isPrivateIPv6(cleanIp);
|
||||
}
|
||||
|
||||
// Hostname patterns that resolve to internal services
|
||||
if (lower.endsWith('.internal') || lower.endsWith('.local') || lower.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some(p => isNaN(p))) return true;
|
||||
|
||||
const [a, b] = parts;
|
||||
|
||||
// 127.0.0.0/8 — loopback
|
||||
if (a === 127) return true;
|
||||
// 10.0.0.0/8 — private
|
||||
if (a === 10) return true;
|
||||
// 172.16.0.0/12 — private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16 — private
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 169.254.0.0/16 — link-local
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIPv6(ip) {
|
||||
const lower = ip.toLowerCase();
|
||||
// ::1 loopback
|
||||
if (lower === '::1' || lower === '0000:0000:0000:0000:0000:0000:0000:0001') return true;
|
||||
// fc00::/7 — unique local
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
||||
// fe80::/10 — link-local
|
||||
if (lower.startsWith('fe80')) return true;
|
||||
// :: unspecified
|
||||
if (lower === '::') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL string, rejecting private/internal targets.
|
||||
* @param {string} urlString - URL to validate
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateExternalUrl(urlString) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
if (isPrivateIP(parsed.hostname)) {
|
||||
return { valid: false, error: 'URL points to a private or internal network address' };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid URL format' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isPrivateIP, validateExternalUrl };
|
||||
@@ -213,17 +213,17 @@ async function validatePasswordInContext(password, context, userData = {}) {
|
||||
// Base validation with gallery-specific options
|
||||
const result = validatePassword(password, galleryOptions);
|
||||
|
||||
// Override validation for common date formats
|
||||
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
|
||||
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||
if (datePattern.test(password)) {
|
||||
// Date format is valid for gallery passwords
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
score: 2,
|
||||
feedback: {}
|
||||
};
|
||||
// Only allow date-format passwords when complexity is 'simple'
|
||||
if (complexityLevel === 'simple') {
|
||||
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||
if (datePattern.test(password)) {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
score: 2,
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Additional gallery-specific checks
|
||||
|
||||
@@ -9,28 +9,8 @@ function getClientIp(req) {
|
||||
if (!req) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
|
||||
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
|
||||
if (firstIp) {
|
||||
return firstIp;
|
||||
}
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor;
|
||||
if (firstIp) {
|
||||
return firstIp.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
req.ip ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.connection?.socket?.remoteAddress ||
|
||||
''
|
||||
);
|
||||
// Use req.ip which respects Express 'trust proxy' setting
|
||||
return req.ip || req.connection?.remoteAddress || '';
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
|
||||
@@ -57,11 +57,6 @@ async function isTokenRevoked(decodedToken) {
|
||||
|
||||
const revoked = await db('revoked_tokens')
|
||||
.where('token_id', tokenId)
|
||||
.orWhere((builder) => {
|
||||
builder
|
||||
.where('user_id', decodedToken.id)
|
||||
.where('revoked_at', '<=', new Date(decodedToken.iat * 1000).toISOString());
|
||||
})
|
||||
.first();
|
||||
|
||||
return !!revoked;
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
const ADMIN_COOKIE_NAME = 'admin_token';
|
||||
const GALLERY_COOKIE_NAME = 'gallery_token';
|
||||
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
||||
const GUEST_COOKIE_PREFIX = 'guest_token_';
|
||||
|
||||
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const secureCookie = (() => {
|
||||
if (typeof process.env.COOKIE_SECURE === 'string') {
|
||||
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
|
||||
}
|
||||
// Default to false so native HTTP installs stay functional. Operators can
|
||||
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
|
||||
return false;
|
||||
/**
|
||||
* Cookie "Secure" flag mode:
|
||||
* - true → always set Secure (HTTPS-only)
|
||||
* - false → never set Secure (allow plain HTTP)
|
||||
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
|
||||
* via Express `trust proxy`). Useful when the same deployment
|
||||
* is reachable over both HTTPS (via reverse proxy) and LAN HTTP.
|
||||
*
|
||||
* Default: follows NODE_ENV (production → true, dev → false) — unchanged
|
||||
* from previous behavior. Users who want the auto mode must opt in with
|
||||
* COOKIE_SECURE=auto in their .env.
|
||||
*/
|
||||
const secureCookieMode = (() => {
|
||||
const raw = typeof process.env.COOKIE_SECURE === 'string'
|
||||
? process.env.COOKIE_SECURE.toLowerCase()
|
||||
: '';
|
||||
if (raw === 'auto') return 'auto';
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
// No env var set → legacy default
|
||||
return process.env.NODE_ENV === 'production';
|
||||
})();
|
||||
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
function buildCookieBaseOptions() {
|
||||
/**
|
||||
* Resolve the Secure flag for a specific response. When in 'auto' mode,
|
||||
* checks req.secure (which reflects the X-Forwarded-Proto header when the
|
||||
* proxy is in the trust list set by `app.set('trust proxy', ...)`). When
|
||||
* called without a `res`, falls back to false — this only happens in code
|
||||
* paths that don't yet have a response object, which we avoid.
|
||||
*/
|
||||
function resolveSecureFlag(res) {
|
||||
if (secureCookieMode === 'auto') {
|
||||
return Boolean(res?.req?.secure);
|
||||
}
|
||||
return secureCookieMode;
|
||||
}
|
||||
|
||||
function buildCookieBaseOptions(res) {
|
||||
const options = {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
secure: resolveSecureFlag(res),
|
||||
sameSite: sameSiteDefault,
|
||||
path: '/',
|
||||
};
|
||||
@@ -30,29 +59,52 @@ function buildCookieBaseOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
||||
function buildCookieOptionsWithExpiry(res, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
||||
return {
|
||||
...buildCookieBaseOptions(),
|
||||
...buildCookieBaseOptions(res),
|
||||
maxAge: maxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for clearing a cookie. We deliberately omit `secure` here: when
|
||||
* a cookie was set over HTTPS (Secure=true) and we later need to clear it
|
||||
* from a response on an HTTP path (or vice versa, in mixed-protocol
|
||||
* deployments with COOKIE_SECURE=auto), specifying `secure` in the clear
|
||||
* options causes some browsers to reject the Set-Cookie delete header.
|
||||
* Browsers match the cookie by (name, domain, path) for deletion, so
|
||||
* leaving Secure off produces a header the browser always accepts.
|
||||
*/
|
||||
function buildClearCookieOptions() {
|
||||
const options = {
|
||||
httpOnly: true,
|
||||
sameSite: sameSiteDefault,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
if (cookieDomain) {
|
||||
options.domain = cookieDomain;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sanitizeSlugForCookie(slug = '') {
|
||||
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function setAdminAuthCookie(res, token) {
|
||||
if (!token) return;
|
||||
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
|
||||
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
|
||||
}
|
||||
|
||||
function clearAdminAuthCookie(res) {
|
||||
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
|
||||
res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions());
|
||||
}
|
||||
|
||||
function setGalleryAuthCookies(res, token, slug) {
|
||||
if (!token) return;
|
||||
const options = buildCookieOptionsWithExpiry();
|
||||
const options = buildCookieOptionsWithExpiry(res);
|
||||
res.cookie(GALLERY_COOKIE_NAME, token, options);
|
||||
if (slug) {
|
||||
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
@@ -61,18 +113,18 @@ function setGalleryAuthCookies(res, token, slug) {
|
||||
}
|
||||
|
||||
function clearGalleryAuthCookies(res, slug) {
|
||||
const baseOptions = buildCookieBaseOptions();
|
||||
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
|
||||
const clearOptions = buildClearCookieOptions();
|
||||
res.clearCookie(GALLERY_COOKIE_NAME, clearOptions);
|
||||
|
||||
const cookies = res.req?.cookies || {};
|
||||
|
||||
if (slug) {
|
||||
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
res.clearCookie(cookieName, baseOptions);
|
||||
res.clearCookie(cookieName, clearOptions);
|
||||
} else {
|
||||
Object.keys(cookies).forEach((name) => {
|
||||
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
|
||||
res.clearCookie(name, baseOptions);
|
||||
res.clearCookie(name, clearOptions);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -115,10 +167,37 @@ function getGalleryTokenFromRequest(req, slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGuestTokenFromRequest(req, slug) {
|
||||
// Primary transport: custom header (set by frontend axios interceptor).
|
||||
const headerToken = req.headers?.['x-guest-token'];
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
|
||||
if (!req.cookies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const cookieName = `${GUEST_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
if (req.cookies[cookieName]) {
|
||||
return req.cookies[cookieName];
|
||||
}
|
||||
}
|
||||
|
||||
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GUEST_COOKIE_PREFIX));
|
||||
if (prefixed) {
|
||||
return req.cookies[prefixed];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADMIN_COOKIE_NAME,
|
||||
GALLERY_COOKIE_NAME,
|
||||
GALLERY_COOKIE_PREFIX,
|
||||
GUEST_COOKIE_PREFIX,
|
||||
sanitizeSlugForCookie,
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
@@ -126,4 +205,5 @@ module.exports = {
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
getGuestTokenFromRequest,
|
||||
};
|
||||
|
||||
@@ -98,6 +98,16 @@ services:
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: picpeak-mailhog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MAILHOG_SMTP_PORT:-1025}:1025"
|
||||
- "${MAILHOG_UI_PORT:-8025}:8025"
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -37,6 +37,11 @@ server {
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
# Re-apply security headers (add_header in location block overrides server-level)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||||
}
|
||||
|
||||
# Cache index.html with revalidation
|
||||
@@ -44,6 +49,11 @@ server {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
# Re-apply security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
Generated
+14
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.24.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.24.0-beta.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
@@ -3020,14 +3020,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -5751,10 +5751,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.6.1",
|
||||
"version": "3.28.2-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { analyticsService } from './services/analytics.service';
|
||||
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { GalleryPage } from './pages/GalleryPage';
|
||||
import { ClientAccessPage } from './pages/ClientAccessPage';
|
||||
import { PreviewPage } from './pages/gallery/PreviewPage';
|
||||
import { LegalPage } from './pages/public/LegalPage';
|
||||
import {
|
||||
@@ -121,6 +122,11 @@ function App() {
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||
<Route path="/gallery/:slug/client-access" element={
|
||||
<GalleryAuthProvider>
|
||||
<ClientAccessPage />
|
||||
</GalleryAuthProvider>
|
||||
} />
|
||||
<Route path="/gallery/:slug/:token?" element={
|
||||
<GalleryAuthProvider>
|
||||
<GalleryPage />
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
|
||||
import { Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AdminGuestDetailProps {
|
||||
eventId: number;
|
||||
guest: AdminGuest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||
|
||||
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-detail', eventId, guest.id],
|
||||
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
|
||||
});
|
||||
|
||||
const selections = data?.selections;
|
||||
const liked = selections?.liked || [];
|
||||
const favorited = selections?.favorited || [];
|
||||
const rated = selections?.rated || [];
|
||||
const commented = selections?.commented || [];
|
||||
|
||||
// "all" view combines the three visual selection types.
|
||||
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
|
||||
const allItems: GridItem[] = [];
|
||||
const seen = new Map<number, GridItem>();
|
||||
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
|
||||
if (!seen.has(photo.id)) {
|
||||
const item: GridItem = { photo, badges: [badge] };
|
||||
seen.set(photo.id, item);
|
||||
allItems.push(item);
|
||||
} else {
|
||||
seen.get(photo.id)!.badges.push(badge);
|
||||
}
|
||||
};
|
||||
liked.forEach((p) => add(p, 'like'));
|
||||
favorited.forEach((p) => add(p, 'favorite'));
|
||||
rated.forEach((r) => add(r.photo, 'rating'));
|
||||
|
||||
const visibleItems: GridItem[] =
|
||||
tab === 'all'
|
||||
? allItems
|
||||
: tab === 'liked'
|
||||
? liked.map((p) => ({ photo: p, badges: ['like'] }))
|
||||
: tab === 'favorited'
|
||||
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
|
||||
: tab === 'rated'
|
||||
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
|
||||
{guest.email && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8">
|
||||
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto p-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{liked.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{t('admin.guests.columns.likes', 'Likes')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{favorited.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Bookmark className="w-3 h-3" />
|
||||
{t('admin.guests.columns.favorites', 'Favorites')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{rated.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{commented.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{t('admin.guests.columns.comments', 'Comments')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
|
||||
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setTab(k)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
tab === k
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.detail.${k}`, k)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'commented' ? (
|
||||
commented.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.noComments', 'No comments')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commented.map((c, idx) => (
|
||||
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(c.photo.thumbnail_url)}
|
||||
alt={c.photo.filename}
|
||||
className="w-16 h-16 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.empty', 'No selections in this category')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{visibleItems.map((item) => (
|
||||
<div key={item.photo.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(item.photo.thumbnail_url)}
|
||||
alt={item.photo.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-1 right-1 flex gap-1">
|
||||
{item.badges.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{item.photo.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AdminGuestDetail } from './AdminGuestDetail';
|
||||
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
import { GuestInviteDialog } from './GuestInviteDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface AdminGuestsListProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
}
|
||||
|
||||
type View = 'list' | 'aggregate';
|
||||
|
||||
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<View>('list');
|
||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-guests', eventId],
|
||||
queryFn: () => guestsService.getEventGuests(eventId),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
|
||||
guestsService.mergeGuests(eventId, keepId, mergeIds),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
|
||||
setMergeMode(false);
|
||||
setMergeSelection([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
|
||||
});
|
||||
|
||||
const handleDelete = (guest: AdminGuest) => {
|
||||
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
|
||||
deleteMutation.mutate(guest.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportGuest(eventId, guest.id, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportAllGuests(eventId, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `event-${eventId}-guests.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMergeSelection = (id: number) => {
|
||||
setMergeSelection((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const performMerge = () => {
|
||||
if (mergeSelection.length < 2) {
|
||||
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
|
||||
return;
|
||||
}
|
||||
const [keepId, ...mergeIds] = mergeSelection;
|
||||
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
|
||||
const confirmMsg = t(
|
||||
'admin.guests.mergeConfirm',
|
||||
'Merge {{count}} guests into {{name}}? This cannot be undone.',
|
||||
{ count: mergeSelection.length, name: keepName || '#' + keepId }
|
||||
);
|
||||
if (window.confirm(confirmMsg)) {
|
||||
mergeMutation.mutate({ keepId, mergeIds });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
|
||||
}
|
||||
|
||||
const guests = data?.guests || [];
|
||||
|
||||
if (view === 'aggregate') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
|
||||
{t('admin.guests.backToList', 'Back to list')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<GuestSelectionsAggregate eventId={eventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.title', 'Guests')} ({guests.length})
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
|
||||
</span>
|
||||
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
|
||||
{t('admin.guests.mergeNow', 'Merge selected')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserPlus className="w-4 h-4" />}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
>
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Grid3x3 className="w-4 h-4" />}
|
||||
onClick={() => setView('aggregate')}
|
||||
>
|
||||
{t('admin.guests.aggregateView', 'By popularity')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMergeMode(true)}
|
||||
disabled={guests.length < 2}
|
||||
>
|
||||
{t('admin.guests.mergeMode', 'Merge')}
|
||||
</Button>
|
||||
<div className="relative group">
|
||||
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
|
||||
{t('admin.guests.exportAll', 'Export all')}
|
||||
</Button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExportAll(fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.empty', 'No guests have registered yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<tr>
|
||||
{mergeMode && <th className="px-4 py-3 w-8" />}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.name', 'Name')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.email', 'Email')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.likes', 'Likes')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.favorites', 'Favorites')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.comments', 'Comments')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.lastSeen', 'Last seen')}
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{guests.map((guest) => (
|
||||
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
|
||||
{mergeMode && (
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSelection.includes(guest.id)}
|
||||
onChange={() => toggleMergeSelection(guest.id)}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{guest.name}
|
||||
{guest.email_verified_at && (
|
||||
<span className="ml-2 text-xs text-green-600">✓</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{guest.email || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.likes}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.favorites}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.comments}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.ratings}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{new Date(guest.last_seen_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedGuest(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.view', 'View details')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.export', 'Export')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExport(guest, fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.forgetGuest', 'Remove guest')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedGuest && (
|
||||
<AdminGuestDetail
|
||||
eventId={eventId}
|
||||
guest={selectedGuest}
|
||||
onClose={() => setSelectedGuest(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteDialogOpen && (
|
||||
<GuestInviteDialog
|
||||
eventId={eventId}
|
||||
eventName={eventName}
|
||||
onClose={() => {
|
||||
setInviteDialogOpen(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { PasswordChangeModal } from './PasswordChangeModal';
|
||||
import { LanguageSelector } from '../common';
|
||||
import { notificationsService } from '../../services/notifications.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { buildResourceUrl, getApiBaseUrl } from '../../utils/url';
|
||||
|
||||
interface AdminHeaderProps {
|
||||
onMenuClick: () => void;
|
||||
@@ -30,6 +31,24 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: brandingSettings } = useQuery({
|
||||
queryKey: ['admin-settings', 'branding'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
if (response.ok) return response.json();
|
||||
return null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
||||
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
|
||||
const resolvedLogoUrl = logoUrl
|
||||
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
|
||||
: '/picpeak-kamera-transparent.png';
|
||||
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -82,10 +101,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* PicPeak logo - sticky to the left on all sizes */}
|
||||
{/* Logo - sticky to the left on all sizes */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
|
||||
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
|
||||
)}
|
||||
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date display - hidden on smaller screens */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -201,6 +201,34 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
|
||||
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.hideSelected', 'Hide')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
|
||||
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.showSelected', 'Show')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
@@ -260,6 +288,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Visibility badge (#172) */}
|
||||
{(photo as any).visibility === 'hidden' && (
|
||||
<div className="absolute top-2 left-2 z-20">
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t('admin.photos.hidden', 'Hidden')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square">
|
||||
{photo.thumbnail_url ? (
|
||||
|
||||
@@ -76,6 +76,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
srcDoc={htmlContent}
|
||||
className="w-full h-[600px] border-0"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Quote,
|
||||
Minus,
|
||||
Undo,
|
||||
Redo,
|
||||
RemoveFormatting,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Code2,
|
||||
Variable,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EmailTemplateEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
variables?: string[];
|
||||
}
|
||||
|
||||
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
content,
|
||||
onChange,
|
||||
variables = [],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSourceMode, setIsSourceMode] = useState(false);
|
||||
const [sourceContent, setSourceContent] = useState(content);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [showVariables, setShowVariables] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
hardBreak: false,
|
||||
}),
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
onChange(html);
|
||||
setSourceContent(html);
|
||||
},
|
||||
});
|
||||
|
||||
// Sync editor when content prop changes externally
|
||||
React.useEffect(() => {
|
||||
if (editor && !isSourceMode && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
setSourceContent(content);
|
||||
}
|
||||
}, [content, editor, isSourceMode]);
|
||||
|
||||
const handleSourceChange = useCallback((value: string) => {
|
||||
setSourceContent(value);
|
||||
onChange(value);
|
||||
}, [onChange]);
|
||||
|
||||
const switchToVisual = useCallback(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceContent);
|
||||
}
|
||||
setIsSourceMode(false);
|
||||
}, [editor, sourceContent]);
|
||||
|
||||
const switchToSource = useCallback(() => {
|
||||
if (editor) {
|
||||
setSourceContent(editor.getHTML());
|
||||
}
|
||||
setIsSourceMode(true);
|
||||
}, [editor]);
|
||||
|
||||
const insertVariable = useCallback((variable: string) => {
|
||||
const tag = `{{${variable}}}`;
|
||||
if (isSourceMode) {
|
||||
// Insert at cursor in textarea
|
||||
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
|
||||
setSourceContent(newContent);
|
||||
onChange(newContent);
|
||||
// Restore cursor position after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
} else if (editor) {
|
||||
editor.chain().focus().insertContent(tag).run();
|
||||
}
|
||||
setShowVariables(false);
|
||||
}, [editor, isSourceMode, sourceContent, onChange]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (linkUrl && editor) {
|
||||
editor.chain().focus().setLink({ href: linkUrl }).run();
|
||||
setLinkUrl('');
|
||||
setShowLinkDialog(false);
|
||||
}
|
||||
}, [editor, linkUrl]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MenuButton: React.FC<{
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, active, children, title, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
|
||||
active
|
||||
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||
: 'text-neutral-700 dark:text-neutral-300'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
{/* Formatting buttons */}
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{!isSourceMode && (
|
||||
<>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title={`${t('email.editor.bold')} (Ctrl+B)`}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title={`${t('email.editor.italic')} (Ctrl+I)`}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title={t('email.editor.bulletList')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title={t('email.editor.numberedList')}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title={t('email.editor.blockquote')}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title={t('email.editor.link')}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title={t('email.editor.horizontalRule')}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title={t('email.editor.alignLeft')}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title={t('email.editor.alignCenter')}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title={t('email.editor.alignRight')}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title={t('email.editor.clearFormatting')}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title={`${t('email.editor.undo')} (Ctrl+Z)`}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title={`${t('email.editor.redo')} (Ctrl+Y)`}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Variables + Source toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{variables.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowVariables(!showVariables)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
showVariables
|
||||
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
<Variable className="w-3.5 h-3.5" />
|
||||
{t('email.editor.insertVariable')}
|
||||
</button>
|
||||
|
||||
{showVariables && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
|
||||
{variables.map(variable => (
|
||||
<button
|
||||
key={variable}
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={isSourceMode ? switchToVisual : switchToSource}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isSourceMode
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addLink()}
|
||||
placeholder={t('email.editor.enterUrl')}
|
||||
className="flex-1 px-3 py-1 text-sm border border-primary-300 dark:border-primary-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
|
||||
type="button"
|
||||
>
|
||||
{t('email.editor.addLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
|
||||
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
|
||||
type="button"
|
||||
>
|
||||
{t('email.editor.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor / Source Content Area */}
|
||||
{isSourceMode ? (
|
||||
<textarea
|
||||
data-email-source=""
|
||||
value={sourceContent}
|
||||
onChange={(e) => handleSourceChange(e.target.value)}
|
||||
rows={15}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: 'simple' | 'guest';
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
|
||||
{settings.feedback_enabled && (
|
||||
<>
|
||||
{/* Identity Mode */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.identityMode', 'Identity Mode')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
(settings.identity_mode || 'simple') === 'simple'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="simple"
|
||||
checked={(settings.identity_mode || 'simple') === 'simple'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeSimpleDesc',
|
||||
'Anonymous, device-based. All visitors on the same device share state.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
settings.identity_mode === 'guest'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="guest"
|
||||
checked={settings.identity_mode === 'guest'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeGuestDesc',
|
||||
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Copy, Check, Trash2 } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { guestsService, GuestInvite } from '../../services/guests.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface GuestInviteDialogProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin dialog to create pre-minted invite tokens and list existing ones.
|
||||
* Each invite generates a unique URL that the admin can send to a specific
|
||||
* guest. Opening the URL auto-registers that guest (single use).
|
||||
*/
|
||||
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-invites', eventId],
|
||||
queryFn: () => guestsService.listInvites(eventId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
|
||||
});
|
||||
|
||||
const copy = (invite: GuestInvite) => {
|
||||
navigator.clipboard.writeText(invite.url).then(() => {
|
||||
setCopiedId(invite.id);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const invites = data?.invites || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.invitesTitle', 'Guest invites')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-4">
|
||||
{/* Create form */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<Input
|
||||
label={t('admin.guests.inviteName', 'Guest name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Alice"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={t('admin.guests.inviteEmail', 'Email (optional)')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="alice@example.com"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('admin.guests.generateInvite', 'Generate invite link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Existing invites */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('admin.guests.existingInvites', 'Existing invites')}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<Loading size="sm" />
|
||||
) : invites.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
|
||||
{t('admin.guests.noInvites', 'No invites yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{invite.guest.name}
|
||||
{invite.guest.email && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
|
||||
· {invite.guest.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs mt-1">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
|
||||
invite.status === 'redeemed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: invite.status === 'revoked'
|
||||
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
|
||||
{invite.url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{invite.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(invite)}
|
||||
className="p-1.5 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.copyLink', 'Copy link')}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeMutation.mutate(invite.id)}
|
||||
className="p-1.5 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.revokeInvite', 'Revoke')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, Loading } from '../common';
|
||||
import { guestsService } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GuestSelectionsAggregateProps {
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows photos sorted by the number of distinct guests who liked or
|
||||
* favorited them. Photos with zero picks are filtered server-side.
|
||||
*/
|
||||
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guests-aggregate', eventId],
|
||||
queryFn: () => guestsService.getAggregatePicks(eventId),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
|
||||
}
|
||||
|
||||
const photos = data?.photos || [];
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'admin.guests.aggregate.description',
|
||||
'Photos sorted by how many distinct guests liked or favorited them.'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(p.thumbnail_url)}
|
||||
alt={p.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
|
||||
<Users className="w-3 h-3" />
|
||||
{p.picker_count}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{p.original_filename || p.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,14 +27,12 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||
mutationFn: adminService.changePassword,
|
||||
onSuccess: () => {
|
||||
toast.success(t('mandatoryPasswordChange.success'));
|
||||
updatePasswordChanged();
|
||||
// Reset form
|
||||
setFormData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setErrors({});
|
||||
// Force a full page reload so the browser picks up the new JWT cookie
|
||||
// set by the backend. A React state update alone causes a race condition
|
||||
// where the auth context checks the session before the cookie is stored.
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/dashboard';
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.error) {
|
||||
|
||||
@@ -30,14 +30,12 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
mutationFn: adminService.changePassword,
|
||||
onSuccess: () => {
|
||||
toast.success(t('passwordChange.success'));
|
||||
onClose();
|
||||
// Reset form
|
||||
setFormData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setErrors({});
|
||||
// Full page reload so the browser picks up the new JWT cookie.
|
||||
// Same fix as MandatoryPasswordChangeModal — without this, the old
|
||||
// token gets rejected and causes a redirect loop.
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/dashboard';
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.error) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film } from 'lucide-react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
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';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeCustomizerEnhancedProps {
|
||||
@@ -90,7 +90,21 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||
const [showCssInstructions, setShowCssInstructions] = useState(false);
|
||||
// const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const BETA_LAYOUTS: GalleryLayoutType[] = ['gallery-premium', 'gallery-story'];
|
||||
const MIN_RECOMMENDED_THUMBNAIL_SIZE = 500;
|
||||
|
||||
// Fetch thumbnail settings to warn about low resolution with beta themes
|
||||
const { data: allSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
const thumbnailWidth = parseInt(allSettings?.thumbnail_width) || 300;
|
||||
const thumbnailHeight = parseInt(allSettings?.thumbnail_height) || 300;
|
||||
const isBetaLayout = BETA_LAYOUTS.includes(localTheme.galleryLayout as GalleryLayoutType);
|
||||
const isThumbnailTooSmall = Math.max(thumbnailWidth, thumbnailHeight) < MIN_RECOMMENDED_THUMBNAIL_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTheme(value);
|
||||
@@ -230,6 +244,33 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warning: Beta preset with low thumbnail resolution */}
|
||||
{isBetaLayout && isThumbnailTooSmall && !showGalleryLayouts && (
|
||||
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
{t('branding.betaThumbnailWarningTitle')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })}
|
||||
</p>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = '/admin/settings';
|
||||
}}
|
||||
>
|
||||
{t('branding.betaThumbnailWarningLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Gallery Layout */}
|
||||
@@ -271,6 +312,33 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warning: Beta theme with low thumbnail resolution */}
|
||||
{isBetaLayout && isThumbnailTooSmall && (
|
||||
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
{t('branding.betaThumbnailWarningTitle')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })}
|
||||
</p>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = '/admin/settings';
|
||||
}}
|
||||
>
|
||||
{t('branding.betaThumbnailWarningLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout-specific settings */}
|
||||
{localTheme.galleryLayout && (
|
||||
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
@@ -312,52 +380,73 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Grid specific */}
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
mobile: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="6"
|
||||
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
tablet: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="8"
|
||||
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
desktop: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
mobile: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="6"
|
||||
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
tablet: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="8"
|
||||
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
desktop: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Carousel specific */}
|
||||
@@ -437,6 +526,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail scale - only for columns mode */}
|
||||
{(!localTheme.gallerySettings?.masonryMode || localTheme.gallerySettings?.masonryMode === 'columns') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row-specific settings - show for all row-based modes */}
|
||||
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<>
|
||||
@@ -476,6 +588,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mosaic specific */}
|
||||
{localTheme.galleryLayout === 'mosaic' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.thumbnailScale', 'Thumbnail Scale')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
|
||||
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
|
||||
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
|
||||
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
|
||||
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
|
||||
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog';
|
||||
export { PhotoFilterPanel } from './PhotoFilterPanel';
|
||||
export { PhotoExportMenu } from './PhotoExportMenu';
|
||||
export { CssTemplateEditor } from './CssTemplateEditor';
|
||||
export { AdminGuestsList } from './AdminGuestsList';
|
||||
export { AdminGuestDetail } from './AdminGuestDetail';
|
||||
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
export { GuestInviteDialog } from './GuestInviteDialog';
|
||||
|
||||
@@ -40,7 +40,7 @@ export const DynamicFavicon: React.FC = () => {
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
// Update document title when company name or tagline changes
|
||||
// Update document title and OG meta tags when company name or tagline changes
|
||||
useEffect(() => {
|
||||
const companyName = settings?.branding_company_name?.trim();
|
||||
const tagline = settings?.branding_company_tagline?.trim();
|
||||
@@ -52,6 +52,33 @@ export const DynamicFavicon: React.FC = () => {
|
||||
} else {
|
||||
document.title = DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
// Update OG meta tags
|
||||
const title = companyName || 'PicPeak';
|
||||
const description = tagline || 'Photo Sharing Platform';
|
||||
|
||||
const updateMeta = (property: string, content: string) => {
|
||||
let meta = document.querySelector(`meta[property="${property}"]`) as HTMLMetaElement | null;
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('property', property);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = content;
|
||||
};
|
||||
|
||||
updateMeta('og:title', document.title);
|
||||
updateMeta('og:site_name', title);
|
||||
updateMeta('og:description', description);
|
||||
|
||||
// Also update standard meta description
|
||||
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
|
||||
if (!metaDesc) {
|
||||
metaDesc = document.createElement('meta');
|
||||
metaDesc.name = 'description';
|
||||
document.head.appendChild(metaDesc);
|
||||
}
|
||||
metaDesc.content = description;
|
||||
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -21,9 +21,37 @@ const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RUFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#FFF" d="M0 0h640v160H0z"/>
|
||||
<path fill="#0039A6" d="M0 160h640v160H0z"/>
|
||||
<path fill="#D52B1E" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PTBRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#009B3A" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FEDF00" d="M320 39.4 590.4 240 320 440.6 49.6 240z"/>
|
||||
<circle fill="#002776" cx="320" cy="240" r="95"/>
|
||||
<path fill="#FFF" d="M226.3 262.8c0-27 12.8-51 32.7-66.3a95.3 95.3 0 0 0-3.5 120.6c-17.8-14.8-29.2-37-29.2-54.3z" opacity=".5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NLFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#AE1C28" d="M0 0h640v160H0z"/>
|
||||
<path fill="#FFF" d="M0 160h640v160H0z"/>
|
||||
<path fill="#21468B" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||
{ code: 'ru', name: 'Русский', Flag: RUFlag },
|
||||
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
||||
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
||||
];
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import type { HeaderStyleType } from '../../types/theme.types';
|
||||
|
||||
@@ -59,6 +60,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
@@ -589,20 +591,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
{/* Legal Links */}
|
||||
<div className="mt-4 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
{guestIdentity?.identity && (
|
||||
<>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
onClick={async () => {
|
||||
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
|
||||
await guestIdentity.forget();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -356,7 +356,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating');
|
||||
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating' | 'capture_date');
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
|
||||
@@ -13,16 +13,20 @@ import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { GuestNamePromptModal } from './GuestNamePromptModal';
|
||||
import { GuestRecoveryModal } from './GuestRecoveryModal';
|
||||
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
|
||||
import type { FilterType } from './GalleryFilter';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||
import type { Photo } from '../../types';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -41,13 +45,35 @@ interface GalleryViewProps {
|
||||
};
|
||||
}
|
||||
|
||||
// Convert default_photo_sort DB value to internal sortBy state
|
||||
const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortDesc: boolean } => {
|
||||
switch (defaultSort) {
|
||||
case 'upload_date_asc':
|
||||
return { sortBy: 'date', sortDesc: false };
|
||||
case 'capture_date_desc':
|
||||
return { sortBy: 'capture_date', sortDesc: true };
|
||||
case 'capture_date_asc':
|
||||
return { sortBy: 'capture_date', sortDesc: false };
|
||||
case 'filename_asc':
|
||||
return { sortBy: 'name', sortDesc: false };
|
||||
case 'filename_desc':
|
||||
return { sortBy: 'name', sortDesc: true };
|
||||
case 'upload_date_desc':
|
||||
default:
|
||||
return { sortBy: 'date', sortDesc: true };
|
||||
}
|
||||
};
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { logout, isClient } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const [defaultSortApplied, setDefaultSortApplied] = useState(false);
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -101,6 +127,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [data?.event?.protection_level]);
|
||||
|
||||
// Apply default photo sort from event settings
|
||||
useEffect(() => {
|
||||
if (!defaultSortApplied && data?.event?.default_photo_sort) {
|
||||
const { sortBy: defaultSortBy, sortDesc: defaultSortDesc } = parseDefaultPhotoSort(data.event.default_photo_sort);
|
||||
setSortBy(defaultSortBy);
|
||||
setSortDesc(defaultSortDesc);
|
||||
setDefaultSortApplied(true);
|
||||
}
|
||||
}, [data?.event?.default_photo_sort, defaultSortApplied]);
|
||||
|
||||
// Get individual protection settings from event
|
||||
const disableRightClick = data?.event?.disable_right_click === true;
|
||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
||||
@@ -280,7 +316,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
@@ -310,7 +346,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// No event theme, use global theme
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
@@ -325,12 +361,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Client visibility toggle handler (#172)
|
||||
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
|
||||
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
|
||||
try {
|
||||
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle visibility:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
try {
|
||||
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk toggle visibility:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Client visibility stats
|
||||
const visibleCount = useMemo(() => {
|
||||
if (!isClient || !data?.photos) return 0;
|
||||
return data.photos.filter(p => p.visibility !== 'hidden').length;
|
||||
}, [isClient, data?.photos]);
|
||||
|
||||
const totalCount = data?.photos?.length || 0;
|
||||
|
||||
// Calculate days until expiration (null means never expires)
|
||||
const daysUntilExpiration = event.expires_at
|
||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||
@@ -382,29 +449,32 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
|
||||
// The flip multiplier reverses that when sortDesc differs from the natural order.
|
||||
const flip = sortDesc ? 1 : -1;
|
||||
photos.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.filename.localeCompare(b.filename);
|
||||
// Natural order is ascending (A-Z); flip when sortDesc=true
|
||||
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
|
||||
case 'size':
|
||||
return b.size - a.size;
|
||||
case 'rating':
|
||||
// Sort by rating (highest first), then by comment count
|
||||
return flip * (b.size - a.size);
|
||||
case 'rating': {
|
||||
const ratingA = a.average_rating || 0;
|
||||
const ratingB = b.average_rating || 0;
|
||||
if (ratingA !== ratingB) {
|
||||
return ratingB - ratingA;
|
||||
return flip * (ratingB - ratingA);
|
||||
}
|
||||
// If ratings are equal, sort by comment count
|
||||
return (b.comment_count || 0) - (a.comment_count || 0);
|
||||
case 'capture_date':
|
||||
// Sort by capture date (from EXIF), fall back to upload date
|
||||
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
|
||||
}
|
||||
case 'capture_date': {
|
||||
const captureDateA = a.captured_at || a.uploaded_at;
|
||||
const captureDateB = b.captured_at || b.uploaded_at;
|
||||
return new Date(captureDateB).getTime() - new Date(captureDateA).getTime();
|
||||
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
|
||||
}
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -418,7 +488,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
|
||||
|
||||
const likeCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||
@@ -619,6 +689,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
onLogout={logout}
|
||||
/>
|
||||
|
||||
@@ -638,8 +709,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const identityMode: 'simple' | 'guest' =
|
||||
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
|
||||
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
|
||||
<>
|
||||
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
|
||||
<GuestRecoveryModal />
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
@@ -733,6 +810,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Client Access Banner (#172) */}
|
||||
{isClient && (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('clientAccess.banner')}
|
||||
</span>
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
|
||||
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
|
||||
</span>
|
||||
</div>
|
||||
{isSelectionMode && selectedPhotos.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
onClick={() => handleBulkVisibility('hidden')}
|
||||
>
|
||||
{t('clientAccess.hideSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
onClick={() => handleBulkVisibility('visible')}
|
||||
>
|
||||
{t('clientAccess.showSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
@@ -794,6 +908,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
isClient={isClient}
|
||||
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -812,5 +929,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface GuestNamePromptModalProps {
|
||||
requireEmail?: boolean;
|
||||
allowCancel?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-wide prompt shown in guest identity mode when no identity exists
|
||||
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
|
||||
* attempt, or manually via `openPrompt()`.
|
||||
*
|
||||
* Includes a link to the recovery flow for users who already registered on
|
||||
* another device.
|
||||
*/
|
||||
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
||||
requireEmail = false,
|
||||
allowCancel = true,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
if (!promptOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
setErrors({});
|
||||
setSubmitError(null);
|
||||
closePrompt();
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!name.trim()) {
|
||||
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
|
||||
}
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
|
||||
}
|
||||
if (requireEmail && !email.trim()) {
|
||||
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await register(name.trim(), email.trim() || undefined);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
{allowCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{t(
|
||||
'gallery.guestPrompt.description',
|
||||
'Your picks will be saved under this name so the photographer knows which photos you love.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={
|
||||
requireEmail
|
||||
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
|
||||
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
|
||||
}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={errors.email}
|
||||
placeholder={t('gallery.guestPrompt.emailPlaceholder', 'you@example.com')}
|
||||
maxLength={255}
|
||||
/>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestPrompt.submit', 'Continue')}
|
||||
</Button>
|
||||
{allowCancel && (
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closePrompt();
|
||||
openRecovery();
|
||||
}}
|
||||
className="text-sm text-primary-600 hover:underline w-full text-center pt-2"
|
||||
>
|
||||
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
/**
|
||||
* Email-based identity recovery flow (Phase 3.2).
|
||||
*
|
||||
* Two steps:
|
||||
* 1) Enter email → server sends a 6-digit code.
|
||||
* 2) Enter code → server returns a guest token, identity restored.
|
||||
*
|
||||
* Opens when the user clicks "I've been here before" in the name prompt.
|
||||
*/
|
||||
export const GuestRecoveryModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
|
||||
useGuestIdentity();
|
||||
|
||||
const [step, setStep] = useState<'email' | 'code'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
if (!recoveryOpen) return null;
|
||||
|
||||
const reset = () => {
|
||||
setStep('email');
|
||||
setEmail('');
|
||||
setCode('');
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
};
|
||||
|
||||
const backToPrompt = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
openPrompt();
|
||||
};
|
||||
|
||||
const handleRequestCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverRequest(email.trim().toLowerCase());
|
||||
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
|
||||
setStep('code');
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^\d{6}$/.test(code.trim())) {
|
||||
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverVerify(email.trim().toLowerCase(), code.trim());
|
||||
// Success: context clears recoveryOpen on success, component will
|
||||
// unmount naturally.
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToPrompt}
|
||||
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('gallery.guestRecovery.back', 'Back')}
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestRecovery.title', 'Recover your picks')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{step === 'email'
|
||||
? t(
|
||||
'gallery.guestRecovery.emailStepDescription',
|
||||
'Enter the email you used before. We will send a 6-digit verification code.'
|
||||
)
|
||||
: t(
|
||||
'gallery.guestRecovery.codeStepDescription',
|
||||
'Enter the 6-digit code we sent to your email.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{info && step === 'code' && (
|
||||
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'email' ? (
|
||||
<form onSubmit={handleRequestCode} className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label={t('gallery.guestRecovery.emailLabel', 'Email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.sendCode', 'Send code')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoCommentsProps {
|
||||
photoId: string;
|
||||
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
if (!commentText.trim()) {
|
||||
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||
}
|
||||
if (requireNameEmail) {
|
||||
// In guest identity mode, name/email come from the guest token — don't
|
||||
// ask for them here.
|
||||
if (requireNameEmail && !isGuestMode) {
|
||||
if (!guestName.trim()) {
|
||||
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||
}
|
||||
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitCommentMutation.mutate({ comment_text: commentText.trim() });
|
||||
return;
|
||||
}
|
||||
|
||||
submitCommentMutation.mutate({
|
||||
comment_text: commentText.trim(),
|
||||
guest_name: guestName.trim() || undefined,
|
||||
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
{/* Comment Form */}
|
||||
{showCommentForm && (
|
||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
|
||||
{requireNameEmail && (
|
||||
{requireNameEmail && !isGuestMode && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder={t('feedback.yourName', 'Your name')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
const handleFavoriteClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitFavoriteMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -25,8 +25,8 @@ interface PhotoFilterBarProps {
|
||||
onCategoryChange: (categoryId: number | string | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
|
||||
photoCount: number;
|
||||
// Feedback filter props
|
||||
feedbackEnabled?: boolean;
|
||||
@@ -82,9 +82,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
className="w-full md:w-auto text-sm md:text-base"
|
||||
>
|
||||
<span className="hidden md:inline">{t('common.sortBy')} </span>
|
||||
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
|
||||
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
||||
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
|
||||
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
||||
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
|
||||
sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') :
|
||||
t('gallery.sortByRating', 'Rating')}
|
||||
</Button>
|
||||
|
||||
@@ -101,6 +102,17 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
>
|
||||
{t('gallery.sortByDate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSortChange('capture_date');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
|
||||
sortBy === 'capture_date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
|
||||
}`}
|
||||
>
|
||||
{t('photoSort.dateTaken', 'Date Taken')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSortChange('name');
|
||||
|
||||
@@ -64,8 +64,13 @@ interface PhotoGridWithLayoutsProps {
|
||||
heroDividerStyle?: HeroDividerStyle;
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
heroImageAnchor?: string;
|
||||
// Welcome message (per-event) for layouts that display it
|
||||
welcomeMessage?: string;
|
||||
// Logout callback for full-page layouts
|
||||
onLogout?: () => void;
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -97,7 +102,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
headerStyle,
|
||||
heroDividerStyle = 'wave',
|
||||
heroImageAnchor = 'center',
|
||||
onLogout
|
||||
welcomeMessage,
|
||||
onLogout,
|
||||
isClient = false,
|
||||
onToggleVisibility
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
@@ -226,7 +234,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
heroLogoVisible,
|
||||
heroLogoSize,
|
||||
heroLogoPosition,
|
||||
welcomeMessage,
|
||||
onLogout,
|
||||
isClient,
|
||||
onToggleVisibility,
|
||||
};
|
||||
|
||||
// Determine if we should show hero header (decoupled from layout)
|
||||
@@ -284,6 +295,13 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Welcome Message - shown for non-fullpage layouts when set */}
|
||||
{!isFullPageLayout && welcomeMessage && (
|
||||
<div className="mb-6 px-4 py-3 rounded-lg bg-card-theme/50 border border-border-theme text-center">
|
||||
<p className="text-sm text-muted-theme whitespace-pre-line">{welcomeMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Mode Controls - Not shown for carousel, full-page layouts, or when controls are hidden */}
|
||||
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && !isFullPageLayout && (
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -63,6 +64,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||
@@ -203,6 +206,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
|
||||
|
||||
const submitLike = async () => {
|
||||
// Guest identity mode: ensure we have a per-person guest token. The
|
||||
// server reads name/email from the token — body values are ignored.
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
// User cancelled the prompt — abort silently.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
setMyLiked(prev => {
|
||||
const next = !prev;
|
||||
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
||||
return next;
|
||||
});
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mode: legacy inline identity modal flow.
|
||||
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
|
||||
if (needIdentity) {
|
||||
setPendingAction({ type: 'like' });
|
||||
@@ -222,6 +252,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
};
|
||||
|
||||
const submitRating = async (value: number) => {
|
||||
// Guest identity mode.
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'rating',
|
||||
rating: value,
|
||||
});
|
||||
setMyRating(value);
|
||||
try {
|
||||
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
|
||||
setAvgRating(Number(fresh.summary?.average_rating) || 0);
|
||||
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
|
||||
} catch {}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Rating submit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mode: legacy inline identity modal flow.
|
||||
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
|
||||
if (needIdentity) {
|
||||
setPendingAction({ type: 'rating', rating: value });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleLikeClick = () => {
|
||||
const handleLikeClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// Guest identity mode: ensure we have a per-person guest token. The
|
||||
// server will read name/email from the token — body values are ignored.
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
// User cancelled the prompt — silently abort.
|
||||
return;
|
||||
}
|
||||
submitLikeMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mode (or no provider at all): legacy inline prompt flow.
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoRatingProps {
|
||||
photoId: string;
|
||||
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
const handleRatingClick = async (rating: number) => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// If clicking the same rating, remove it
|
||||
const newRating = rating === currentRating ? 0 : rating;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitRatingMutation.mutate({ rating: newRating });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setPendingRating(newRating);
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email
|
||||
});
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface BaseGalleryLayoutProps {
|
||||
};
|
||||
// Logout callback for full-page layouts
|
||||
onLogout?: () => void;
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
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 guestIdentity = useGuestIdentityOptional();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
|
||||
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: currentPhoto.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
|
||||
import { galleryService } from '../../../services/gallery.service';
|
||||
import { analyticsService } from '../../../services/analytics.service';
|
||||
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
|
||||
|
||||
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
onFeedbackChange?.();
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingLikePhotoId(photo.id);
|
||||
setShowIdentityModal(true);
|
||||
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
|
||||
|
||||
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
|
||||
setSavedIdentity({ name, email });
|
||||
@@ -343,7 +367,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
<div
|
||||
className="gallery-premium-hero-bg"
|
||||
style={{
|
||||
backgroundImage: heroPhoto ? `url(${heroPhoto.thumbnail_url || heroPhoto.url})` : undefined
|
||||
backgroundImage: heroPhoto ? `url(${heroPhoto.hero_url || heroPhoto.url})` : undefined
|
||||
}}
|
||||
/>
|
||||
<div className="gallery-premium-hero-overlay" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
StoryFeedbackSheet,
|
||||
StoryScrollToTop
|
||||
} from './story';
|
||||
import { PhotoLightbox } from '../PhotoLightbox';
|
||||
|
||||
import './GalleryStoryLayout.css';
|
||||
|
||||
@@ -34,6 +35,7 @@ interface CategoryScene {
|
||||
|
||||
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
|
||||
heroPhotoOverride?: Photo | null;
|
||||
welcomeMessage?: string;
|
||||
}
|
||||
|
||||
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
@@ -55,6 +57,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
heroPhotoOverride,
|
||||
welcomeMessage,
|
||||
onLogout
|
||||
}) => {
|
||||
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
||||
@@ -69,6 +72,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [favorites, setFavorites] = useState<Set<number>>(new Set());
|
||||
const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({});
|
||||
const [ratings, setRatings] = useState<Record<number, number>>({});
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
@@ -110,7 +114,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
|
||||
// Group by category
|
||||
filteredPhotos.forEach(photo => {
|
||||
const categoryName = photo.category_name || t('gallery.uncategorized', 'Gallery');
|
||||
const categoryName = photo.category_name || '';
|
||||
if (!photosByCategory[categoryName]) {
|
||||
photosByCategory[categoryName] = [];
|
||||
}
|
||||
@@ -161,6 +165,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
setSelectedPhotoForFeedback(photo);
|
||||
}, []);
|
||||
|
||||
const handleOpenLightbox = useCallback((photo: Photo) => {
|
||||
const index = photos.findIndex(p => p.id === photo.id);
|
||||
setLightboxIndex(index >= 0 ? index : 0);
|
||||
}, [photos]);
|
||||
|
||||
const handleCloseFeedback = useCallback(() => {
|
||||
setSelectedPhotoForFeedback(null);
|
||||
}, []);
|
||||
@@ -310,7 +319,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
photos={scene.photos}
|
||||
favorites={favorites}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onPhotoClick={handleOpenFeedback}
|
||||
onPhotoClick={handleOpenLightbox}
|
||||
slug={slug}
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
@@ -326,7 +335,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
index={index}
|
||||
isFavorite={favorites.has(photo.id)}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onClick={() => handleOpenFeedback(photo)}
|
||||
onClick={() => handleOpenLightbox(photo)}
|
||||
slug={slug}
|
||||
galleryId={`gallery-${scene.id}`}
|
||||
allowDownloads={allowDownloads}
|
||||
@@ -348,7 +357,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
<footer className="story-footer">
|
||||
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
|
||||
<p className="story-footer-text">
|
||||
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
||||
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
||||
</p>
|
||||
{allowDownloads && (
|
||||
<button className="story-footer-btn" onClick={handleDownloadAll}>
|
||||
@@ -357,6 +366,22 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<PhotoLightbox
|
||||
photos={photos}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
allowDownloads={allowDownloads}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feedback Sheet */}
|
||||
{feedbackEnabled && (
|
||||
<StoryFeedbackSheet
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } 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';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
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 (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
@@ -365,13 +386,19 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
feedbackOptions,
|
||||
isClient = false,
|
||||
onToggleVisibility
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
||||
const spacing = gallerySettings.spacing || 'normal';
|
||||
const animation = gallerySettings.photoAnimation || 'fade';
|
||||
const scale = gallerySettings.thumbnailScale || 'md';
|
||||
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
@@ -379,49 +406,70 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||
|
||||
|
||||
const gridClass = `photo-grid grid ${spacingClass}
|
||||
grid-cols-${columns.mobile}
|
||||
sm:grid-cols-${columns.tablet}
|
||||
lg:grid-cols-${columns.desktop}
|
||||
xl:grid-cols-${columns.desktop + 1}`;
|
||||
grid-cols-${applyScale(columns.mobile)}
|
||||
sm:grid-cols-${applyScale(columns.tablet)}
|
||||
lg:grid-cols-${applyScale(columns.desktop)}
|
||||
xl:grid-cols-${applyScale(columns.desktop + 1)}`;
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{photos.map((photo, index) => (
|
||||
<GridPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{photos.map((photo, index) => {
|
||||
const isHidden = photo.visibility === 'hidden';
|
||||
return (
|
||||
<div key={photo.id} className={`relative ${isClient && isHidden ? 'opacity-40' : ''}`}>
|
||||
<GridPhoto
|
||||
photo={photo}
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* Client visibility toggle overlay (#172) */}
|
||||
{isClient && onToggleVisibility && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(photo.id, photo.visibility || 'visible');
|
||||
}}
|
||||
className={`absolute top-2 left-2 z-10 p-1.5 rounded-full shadow-md transition-colors ${
|
||||
isHidden
|
||||
? 'bg-red-500/90 text-white hover:bg-red-600'
|
||||
: 'bg-white/90 text-neutral-700 hover:bg-white dark:bg-neutral-800/90 dark:text-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
title={isHidden ? 'Hidden from guests' : 'Visible to guests'}
|
||||
>
|
||||
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FeedbackIdentityModal
|
||||
isOpen={showIdentityModal}
|
||||
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
const overlayTimeoutRef = useRef<number | null>(null);
|
||||
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
@@ -33,6 +34,9 @@ interface MasonryPhotoProps {
|
||||
onQuickComment?: () => void;
|
||||
// Column width for calculating proper aspect-ratio-based height
|
||||
columnWidth?: number;
|
||||
// Optimistic "I liked this" state + callback (lifted to parent)
|
||||
liked?: boolean;
|
||||
onLikeSuccess?: () => void;
|
||||
}
|
||||
|
||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
@@ -48,11 +52,14 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
slug,
|
||||
feedbackOptions,
|
||||
onQuickComment,
|
||||
columnWidth = 300
|
||||
columnWidth = 300,
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
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 guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Calculate height based on actual photo aspect ratio
|
||||
// This preserves the photo's natural proportions in the masonry layout
|
||||
@@ -148,24 +155,56 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
)}
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
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 (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Optimistic UI: mark as liked immediately
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
// 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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
}}
|
||||
aria-label="Like photo"
|
||||
title="Like"
|
||||
aria-label={liked ? 'Unlike photo' : 'Like photo'}
|
||||
aria-pressed={liked}
|
||||
title={liked ? 'Unlike' : 'Like'}
|
||||
>
|
||||
<Heart className="w-5 h-5 text-neutral-800" />
|
||||
<Heart
|
||||
className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -180,6 +219,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
setSavedIdentity({ name, email });
|
||||
setShowIdentityModal(false);
|
||||
if (pendingAction) {
|
||||
if (pendingAction.type === 'like' && onLikeSuccess) {
|
||||
onLikeSuccess();
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
|
||||
feedback_type: pendingAction.type,
|
||||
guest_name: name,
|
||||
@@ -236,11 +278,21 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(3);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
// Optimistic "I liked this" state — lifted here so it survives re-renders
|
||||
// of individual MasonryPhoto components during layout reflow/resize.
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const gutter = gallerySettings.masonryGutter || 16;
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
||||
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
||||
const scale = gallerySettings.thumbnailScale || 'md';
|
||||
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
// Apply scale to columns only in columns mode
|
||||
const scaledColumns = mode === 'columns' ? applyScale(columns) : columns;
|
||||
|
||||
|
||||
// Calculate number of columns based on container width (for columns mode)
|
||||
@@ -339,20 +391,20 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// 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 }, () => []);
|
||||
return Array.from({ length: scaledColumns }, () => []);
|
||||
}
|
||||
|
||||
const cols: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
const colHeights: number[] = Array(columns).fill(0);
|
||||
const cols: Photo[][] = Array.from({ length: scaledColumns }, () => []);
|
||||
const colHeights: number[] = Array(scaledColumns).fill(0);
|
||||
|
||||
// Calculate approximate column width for height estimation
|
||||
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
|
||||
const approxColWidth = containerWidth > 0 ? (containerWidth - (scaledColumns - 1) * gutter) / scaledColumns : 300;
|
||||
|
||||
photos.forEach((photo) => {
|
||||
// Find the shortest column
|
||||
let shortestCol = 0;
|
||||
let minHeight = colHeights[0];
|
||||
for (let i = 1; i < columns; i++) {
|
||||
for (let i = 1; i < scaledColumns; i++) {
|
||||
if (colHeights[i] < minHeight) {
|
||||
minHeight = colHeights[i];
|
||||
shortestCol = i;
|
||||
@@ -373,15 +425,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [mode, photos, columns, containerWidth, gutter]);
|
||||
}, [mode, photos, scaledColumns, containerWidth, gutter]);
|
||||
|
||||
// Calculate approximate column width for aspect ratio calculations
|
||||
const columnWidth = useMemo(() => {
|
||||
if (containerWidth <= 0 || columns <= 0) return 300;
|
||||
if (containerWidth <= 0 || scaledColumns <= 0) return 300;
|
||||
// Account for gaps between columns
|
||||
const totalGaps = (columns - 1) * gutter;
|
||||
return (containerWidth - totalGaps) / columns;
|
||||
}, [containerWidth, columns, gutter]);
|
||||
const totalGaps = (scaledColumns - 1) * gutter;
|
||||
return (containerWidth - totalGaps) / scaledColumns;
|
||||
}, [containerWidth, scaledColumns, gutter]);
|
||||
|
||||
// ROWS MODE - Google Photos style justified layout
|
||||
if (mode === 'rows') {
|
||||
@@ -778,6 +830,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
|
||||
columnWidth={columnWidth}
|
||||
liked={likedPhotoIds.has(photo.id)}
|
||||
onLikeSuccess={() => {
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -52,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||
|
||||
@@ -107,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
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 (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedLocal(true);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
@@ -210,23 +227,33 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const scale = theme.gallerySettings?.thumbnailScale || 'md';
|
||||
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
|
||||
const applyScale = (cols: number, min = 1) => Math.max(min, cols + (scaleOffsets[scale] ?? 0));
|
||||
|
||||
const desktop = applyScale(4);
|
||||
const xlDown = applyScale(3);
|
||||
const lgDown = applyScale(2);
|
||||
const mobile = Math.min(applyScale(1), 2); // Cap mobile at 2
|
||||
|
||||
return (
|
||||
<div
|
||||
className="photo-grid w-full"
|
||||
style={{
|
||||
columnCount: 4,
|
||||
columnCount: desktop,
|
||||
columnGap: '8px',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@media (max-width: 1280px) {
|
||||
.photo-grid { column-count: 3 !important; }
|
||||
.photo-grid { column-count: ${xlDown} !important; }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.photo-grid { column-count: 2 !important; }
|
||||
.photo-grid { column-count: ${lgDown} !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.photo-grid { column-count: 1 !important; }
|
||||
.photo-grid { column-count: ${mobile} !important; }
|
||||
}
|
||||
`}</style>
|
||||
{photos.map((photo, index) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
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 guestIdentity = useGuestIdentityOptional();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||
const showDates = gallerySettings.timelineShowDates !== false;
|
||||
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
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 (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
src={photo.url}
|
||||
alt={photo.filename}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user