Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f497c5f1 | |||
| b52cf1f741 | |||
| 7f7736282f | |||
| 67b0f32456 | |||
| 25b40c03b0 | |||
| 28793bba68 | |||
| 4ae91142f8 | |||
| c92879fbd3 | |||
| a0bb080586 | |||
| fc75bcdfc3 | |||
| 9877f63aed | |||
| 0c98c6b453 | |||
| 831ea6a3bc | |||
| 9c44a0ebfa | |||
| 08ac238d0a | |||
| 7d967a47ae | |||
| 9b7495e005 | |||
| e1ad4219a5 | |||
| cc4503ad28 | |||
| 424336340b | |||
| a8308a5c02 | |||
| 02a46e083d | |||
| 98fd6dd8e1 | |||
| 3a30fea862 | |||
| 7d6d2f5688 | |||
| b5074e4e46 | |||
| a1d941f049 | |||
| 80171713e0 | |||
| c0301dcbf4 | |||
| cbecb9323c | |||
| 4272618b3f | |||
| fe07a148f1 | |||
| 0ec4190e2e | |||
| 0ec3787150 | |||
| 59faf73f04 | |||
| 3e0c4fd73e | |||
| 33af088560 | |||
| 5ea4ef3cf3 | |||
| 33483cf32d | |||
| cd00bc13d4 | |||
| 26ec9666b9 | |||
| f672c1daa6 | |||
| 5f1f0f253d | |||
| 888c4ab209 | |||
| 551d9cc66f | |||
| 9045402c9a | |||
| 0817443e79 | |||
| a4c624802b | |||
| 79cf4100a1 | |||
| fe9486e5fa | |||
| bcf2745ab6 | |||
| c4f16eb76c | |||
| 5925ea8406 | |||
| 6613f1b088 | |||
| 3ea9d5b121 | |||
| 0891be197f | |||
| 2b25d81144 | |||
| 50c09904a9 | |||
| 7aa37b2447 | |||
| d239857d9a | |||
| 3974ba5de5 | |||
| 5cef7fdd18 | |||
| 092f007ed3 | |||
| edf3a43950 | |||
| 45d78c0dce | |||
| d63f67a2af | |||
| ad00eae251 | |||
| 2c35543e73 | |||
| 7c75736719 | |||
| 9c2a0d272a | |||
| 4912e2bccf | |||
| f8c8abd70b | |||
| 7726adeff0 | |||
| e05fd64760 | |||
| 4280444d70 | |||
| 6491184402 | |||
| 171abb3161 | |||
| e179def3cc | |||
| bc6c48bb24 | |||
| 57845a5508 | |||
| 10ff6b118c | |||
| 2288309395 | |||
| a19e7c40a2 | |||
| de56cd0dce | |||
| 8ddec6ed8b | |||
| d9e00dc0db | |||
| 6c30e2c2ed | |||
| 329d224846 | |||
| 734868abc2 | |||
| f554f463b3 | |||
| fa4c83812d | |||
| 8cc5685428 | |||
| 7bf1e5c0f9 | |||
| 7037106bff | |||
| eca36c70a2 | |||
| 7b8d8bd92b | |||
| 397d33a95a | |||
| 08c2e4530e | |||
| 9ec0e2e7c0 | |||
| aacfcd517e | |||
| 27ff51e7a1 | |||
| 821d3296ea | |||
| 46ed1bc276 | |||
| 8711f967a1 | |||
| 5c8aed5793 | |||
| c40f34d3de | |||
| ef2ae00ff2 | |||
| 608bbd50e7 | |||
| e3024e6ffd | |||
| b4978c0869 | |||
| cd1d50474f | |||
| 8097a0cb53 | |||
| e081b56a44 | |||
| c2309af3e0 | |||
| 32fc939c7a | |||
| 4c081601e0 | |||
| 85170b883f | |||
| c018604e5d | |||
| 9c8b5e9fd6 | |||
| 151e1bf50f | |||
| c5a8ffc08c | |||
| d4a15dbe74 | |||
| 0790a1ddad | |||
| f8881d5bd6 | |||
| 6b3ead747b | |||
| dadef81158 | |||
| 644ea22b5f | |||
| f4da354ae7 | |||
| a59f41463f | |||
| 9872ad3aef | |||
| d0880ccb03 | |||
| 237eeea5a6 | |||
| 41bf6ff884 | |||
| 991aa98f98 | |||
| 86fa1046d5 | |||
| 3397807670 | |||
| cdda709886 | |||
| 023bb97e66 | |||
| cf38305f28 | |||
| 0e3674b2b0 | |||
| 3ccb8154eb | |||
| b5ac18121d | |||
| b613f8fbc7 | |||
| cacaffa5c3 | |||
| 691e3aba09 | |||
| 70a0caa11f | |||
| e808e529cd | |||
| 05a5307e22 | |||
| 2a2c23d116 | |||
| a092d98523 | |||
| b5f06af126 | |||
| 6cb43428d1 | |||
| df7dbffbff | |||
| 94421a6b12 | |||
| 7805e89bfe | |||
| 3079eaa2e5 | |||
| 2151147f2d | |||
| 3e69579f5a | |||
| 808ed1d2f1 | |||
| b40e085d28 | |||
| d603567e21 | |||
| c6fdd38e84 | |||
| ae181cf92f | |||
| 1be974afbb | |||
| 4c0baf242b | |||
| b12621b994 | |||
| 4701edc12e | |||
| d29aab7c70 | |||
| 41f80fc898 | |||
| 0f7551ab5b | |||
| 7c58749806 | |||
| 050ed37819 | |||
| 83a4344a01 | |||
| 1c8f686c19 | |||
| a0f38053d3 | |||
| cb012186d9 |
@@ -22,6 +22,7 @@ REDIS_PASSWORD=your_secure_redis_password_here
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
@@ -39,6 +40,11 @@ EMAIL_FROM=noreply@yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
|
||||
@@ -9,7 +9,7 @@ assignees: ''
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please email security@example.com with the details.
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
+21
@@ -86,6 +86,14 @@ docs/*_PLAN.md
|
||||
docs/test-*.md
|
||||
docs/feature-*.md
|
||||
|
||||
# Scaffolding documentation (local development reference)
|
||||
docs/DATABASE_SCHEMA.md
|
||||
docs/BACKEND_SERVICES.md
|
||||
docs/API_ROUTES.md
|
||||
docs/FRONTEND_ARCHITECTURE.md
|
||||
docs/DEVELOPER_ONBOARDING.md
|
||||
docs/ENVIRONMENT_VARIABLES.md
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
@@ -94,3 +102,16 @@ backup/
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
backend/*.db
|
||||
|
||||
# Test files and artifacts
|
||||
test-images/
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.0.0-beta.0"
|
||||
".": "3.17.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.3"
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+413
-1
@@ -5,11 +5,423 @@ 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.3.3](https://github.com/the-luap/picpeak/compare/v2.3.2...v2.3.3) (2026-01-15)
|
||||
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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)
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
|
||||
## [3.17.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.16.0-beta.0...v3.17.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
|
||||
## [3.16.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.3-beta.0...v3.16.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 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))
|
||||
|
||||
## [3.15.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.2-beta.0...v3.15.3-beta.0) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
* 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))
|
||||
* **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))
|
||||
|
||||
## [2.6.0](https://github.com/the-luap/picpeak/compare/v2.5.1...v2.6.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 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))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
|
||||
## [2.5.1](https://github.com/the-luap/picpeak/compare/v2.5.0...v2.5.1) (2026-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
|
||||
|
||||
## [2.5.0](https://github.com/the-luap/picpeak/compare/v2.4.0...v2.5.0) (2026-02-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
* 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 customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* 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 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 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 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)
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
* 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))
|
||||
* 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))
|
||||
* 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))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
* 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))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
## [3.15.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.1-beta.0...v3.15.2-beta.0) (2026-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* 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)
|
||||
* 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))
|
||||
* 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))
|
||||
* 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))
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* 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))
|
||||
* 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))
|
||||
* 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 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 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))
|
||||
* 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))
|
||||
* 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)
|
||||
* 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 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))
|
||||
* 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))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* 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) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
|
||||
## [3.15.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.0-beta.0...v3.15.1-beta.0) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
* 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))
|
||||
|
||||
## [3.15.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.14.0-beta.0...v3.15.0-beta.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
|
||||
## [3.14.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.1-beta.0...v3.14.0-beta.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 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)
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
|
||||
## [3.13.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.0-beta.0...v3.13.1-beta.0) (2026-02-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
|
||||
## [3.13.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.12.0-beta.0...v3.13.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 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))
|
||||
|
||||
## [3.12.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.11.0-beta.0...v3.12.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* 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))
|
||||
|
||||
## [3.11.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.1-beta.0...v3.11.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* 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))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
* 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))
|
||||
|
||||
## [3.10.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.0-beta.0...v3.10.1-beta.0) (2026-02-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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))
|
||||
|
||||
## [3.10.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.9.0-beta.0...v3.10.0-beta.0) (2026-02-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 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 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))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 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 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))
|
||||
* 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))
|
||||
|
||||
## [3.9.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.8.0-beta.0...v3.9.0-beta.0) (2026-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
|
||||
|
||||
## [3.8.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.7.0-beta.0...v3.8.0-beta.0) (2026-01-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
|
||||
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
|
||||
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
|
||||
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
|
||||
|
||||
## [3.7.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.6.0-beta.0...v3.7.0-beta.0) (2026-01-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
|
||||
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
|
||||
|
||||
## [3.6.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.5.0-beta.0...v3.6.0-beta.0) (2026-01-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
|
||||
## [3.5.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.4.0-beta.0...v3.5.0-beta.0) (2026-01-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
|
||||
## [3.4.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.3.0-beta.0...v3.4.0-beta.0) (2026-01-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
|
||||
## [3.3.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.5-beta.0...v3.3.0-beta.0) (2026-01-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
|
||||
## [3.2.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.4-beta.0...v3.2.5-beta.0) (2026-01-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
|
||||
## [3.2.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.3-beta.0...v3.2.4-beta.0) (2026-01-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
|
||||
## [3.2.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.2-beta.0...v3.2.3-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
|
||||
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
|
||||
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
|
||||
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
|
||||
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* 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))
|
||||
|
||||
@@ -7,12 +7,27 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
|
||||
</div>
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
Try PicPeak without installing anything:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
@@ -68,9 +83,9 @@ cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3005
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
@@ -113,8 +128,8 @@ PICPEAK_CHANNEL=v2.3.0
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml pull
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
@@ -327,6 +342,8 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
|
||||
+6
-6
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Report the vulnerability by:
|
||||
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Mark it clearly as "SECURITY" in the title
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
@@ -14,6 +14,11 @@ ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
@@ -39,6 +44,7 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
|
||||
+3
-8
@@ -11,10 +11,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
@@ -31,12 +27,11 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains database migrations for the Wedding Photo Sharing platform.
|
||||
This directory contains database migrations for the PicPeak photo sharing platform.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
|
||||
- `init.js` - Initial database schema creation
|
||||
- Backup service tables (029-035)
|
||||
- Gallery feedback tables (033)
|
||||
- Pre-generated watermarks (061)
|
||||
|
||||
### `/legacy`
|
||||
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
|
||||
|
||||
@@ -14,8 +14,8 @@ exports.up = async function(knex) {
|
||||
// Create default admin user if none exists
|
||||
const adminExists = await knex('admin_users').first();
|
||||
if (!adminExists) {
|
||||
// Generate a secure random password
|
||||
const generatedPassword = generateReadablePassword();
|
||||
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
|
||||
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||
|
||||
// Get admin credentials from environment or use defaults
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Migration: Add event_types table
|
||||
*
|
||||
* Creates a customizable event types system to replace hardcoded event types.
|
||||
* This allows users to:
|
||||
* - Rename existing event types (wedding, birthday, corporate, other)
|
||||
* - Create custom event types with custom slug prefixes
|
||||
* - Associate default theme presets with event types
|
||||
*
|
||||
* Backward compatible: Existing events keep their event_type strings.
|
||||
* New events can use either legacy strings or custom event type slug_prefix.
|
||||
*/
|
||||
|
||||
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating event_types table...');
|
||||
|
||||
// Create event_types table
|
||||
const hasEventTypesTable = await knex.schema.hasTable('event_types');
|
||||
if (!hasEventTypesTable) {
|
||||
await knex.schema.createTable('event_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
|
||||
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
|
||||
table.string('emoji', 10); // Icon emoji: "👨👩👧"
|
||||
table.string('theme_preset', 50); // Default theme: "elegantWedding"
|
||||
table.text('theme_config'); // Custom theme JSON overrides (optional)
|
||||
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
|
||||
table.boolean('is_system').defaultTo(false); // Protect default types
|
||||
table.boolean('is_active').defaultTo(true); // Allow hiding types
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for performance
|
||||
table.index('slug_prefix');
|
||||
table.index('display_order');
|
||||
table.index('is_active');
|
||||
});
|
||||
console.log('event_types table created');
|
||||
} else {
|
||||
console.log('event_types table already exists, skipping creation');
|
||||
}
|
||||
|
||||
// Seed default event types (matching current hardcoded values)
|
||||
const existingTypes = await knex('event_types').select('slug_prefix');
|
||||
const existingSlugs = existingTypes.map(t => t.slug_prefix);
|
||||
|
||||
const defaultTypes = [
|
||||
{
|
||||
name: 'Wedding',
|
||||
slug_prefix: 'wedding',
|
||||
emoji: '💒',
|
||||
theme_preset: 'elegantWedding',
|
||||
display_order: 1,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Birthday',
|
||||
slug_prefix: 'birthday',
|
||||
emoji: '🎂',
|
||||
theme_preset: 'birthdayFun',
|
||||
display_order: 2,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Corporate',
|
||||
slug_prefix: 'corporate',
|
||||
emoji: '🏢',
|
||||
theme_preset: 'corporateTimeline',
|
||||
display_order: 3,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Other',
|
||||
slug_prefix: 'other',
|
||||
emoji: '📸',
|
||||
theme_preset: 'default',
|
||||
display_order: 4,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
}
|
||||
];
|
||||
|
||||
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
|
||||
|
||||
if (typesToInsert.length > 0) {
|
||||
await knex('event_types').insert(typesToInsert);
|
||||
console.log(`Inserted ${typesToInsert.length} default event types`);
|
||||
} else {
|
||||
console.log('Default event types already exist, skipping seed');
|
||||
}
|
||||
|
||||
console.log('Migration 061_add_event_types_table completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back event_types table...');
|
||||
|
||||
// Drop the table (data will be lost)
|
||||
await knex.schema.dropTableIfExists('event_types');
|
||||
|
||||
console.log('event_types table dropped');
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Migration: Add optional event date and expiration settings
|
||||
* These settings control whether event_date and expiration are required
|
||||
* when creating new events, supporting non-event use cases like portraits.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add new settings for optional date and expiration
|
||||
const settings = [
|
||||
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Make event_date and expires_at columns nullable
|
||||
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
|
||||
// SQLite requires table recreation (handled differently)
|
||||
const client = knex.client.config.client;
|
||||
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
// PostgreSQL: directly alter columns
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
|
||||
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
|
||||
// SQLite: columns are already effectively nullable in most cases
|
||||
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
|
||||
// For safety, we'll skip the schema change for SQLite as it's complex
|
||||
// The application logic will handle null values appropriately
|
||||
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove the settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.del();
|
||||
|
||||
// Note: We don't restore NOT NULL constraints as that could fail
|
||||
// if there are existing NULL values in the database
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Migration 061: Add pre-generated watermark path to photos table
|
||||
* - photos.watermark_path: path to pre-generated watermarked image
|
||||
* - photos.watermark_generated_at: timestamp of watermark generation
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 061_add_watermark_path');
|
||||
|
||||
// photos.watermark_path (nullable - path to pre-generated watermarked image)
|
||||
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
|
||||
table.string('watermark_path', 512);
|
||||
});
|
||||
|
||||
// photos.watermark_generated_at (nullable - when watermark was last generated)
|
||||
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
|
||||
table.timestamp('watermark_generated_at');
|
||||
});
|
||||
|
||||
console.log('Migration 061_add_watermark_path completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 061_add_watermark_path');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Migration: Add hero logo customization settings to events table
|
||||
*
|
||||
* Allows per-event customization of the hero gallery logo:
|
||||
* - hero_logo_visible: Show/hide the logo overlay
|
||||
* - hero_logo_size: Logo size (small, medium, large, xlarge)
|
||||
* - hero_logo_position: Logo position (top, center, bottom)
|
||||
*
|
||||
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
console.log('Adding hero logo settings to events table...');
|
||||
|
||||
// Add hero_logo_visible column
|
||||
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||
if (!hasVisibleColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
|
||||
});
|
||||
console.log('Added hero_logo_visible column');
|
||||
}
|
||||
|
||||
// Add hero_logo_size column
|
||||
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||
if (!hasSizeColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
|
||||
});
|
||||
console.log('Added hero_logo_size column');
|
||||
}
|
||||
|
||||
// Add hero_logo_position column
|
||||
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||
if (!hasPositionColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
|
||||
});
|
||||
console.log('Added hero_logo_position column');
|
||||
}
|
||||
|
||||
console.log('Migration 062_add_hero_logo_settings completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
console.log('Rolling back hero logo settings...');
|
||||
|
||||
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||
if (hasVisibleColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_visible');
|
||||
});
|
||||
}
|
||||
|
||||
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||
if (hasSizeColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_size');
|
||||
});
|
||||
}
|
||||
|
||||
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||
if (hasPositionColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_position');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Hero logo settings columns dropped');
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Migration 062: Add original_filename to photos table
|
||||
* - photos.original_filename: preserves the original filename from upload
|
||||
* This enables Lightroom integration by exporting filtered filenames
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 062_add_original_filename');
|
||||
|
||||
// photos.original_filename (nullable - original filename before renaming)
|
||||
await addColumnIfNotExists(knex, 'photos', 'original_filename', (table) => {
|
||||
table.string('original_filename', 512);
|
||||
});
|
||||
|
||||
console.log('Migration 062_add_original_filename completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 062_add_original_filename');
|
||||
// Keep column (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Migration: Add custom logo support to events table
|
||||
*
|
||||
* Allows per-event custom logo that overrides the global branding logo:
|
||||
* - hero_logo_url: Public path to the uploaded custom logo
|
||||
* - hero_logo_path: Full filesystem path to the custom logo
|
||||
*
|
||||
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
|
||||
*
|
||||
* Addresses GitHub Issue #138: Per-event custom logo option
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
console.log('Adding custom logo columns to events table...');
|
||||
|
||||
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||
if (!hasUrlColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_url', 500).nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Added hero_logo_url column');
|
||||
}
|
||||
|
||||
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||
if (!hasPathColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_path', 500).nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Added hero_logo_path column');
|
||||
}
|
||||
|
||||
console.log('Migration 063_add_event_custom_logo completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
console.log('Rolling back custom logo columns...');
|
||||
|
||||
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||
if (hasUrlColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_url');
|
||||
});
|
||||
}
|
||||
|
||||
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||
if (hasPathColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_path');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Custom logo columns dropped');
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Migration: Backfill photo dimensions
|
||||
*
|
||||
* This migration extracts width/height from existing photos that don't have
|
||||
* these dimensions stored. This is needed for aspect-ratio-aware layouts
|
||||
* (masonry, mosaic, justified) to work properly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Check if the width/height columns exist
|
||||
const hasWidth = await knex.schema.hasColumn('photos', 'width');
|
||||
const hasHeight = await knex.schema.hasColumn('photos', 'height');
|
||||
|
||||
if (!hasWidth || !hasHeight) {
|
||||
console.log('[Migration 064] Width/height columns not found, skipping backfill');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get storage path
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Find photos without dimensions
|
||||
const photos = await knex('photos')
|
||||
.whereNull('width')
|
||||
.orWhereNull('height')
|
||||
.select('id', 'path', 'filename', 'media_type');
|
||||
|
||||
console.log(`[Migration 064] Found ${photos.length} photos without dimensions`);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import sharp dynamically (only needed during migration)
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
console.error('[Migration 064] Sharp not available, skipping backfill:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
// Skip videos - they need ffprobe for metadata
|
||||
if (photo.media_type === 'video') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Construct the full file path
|
||||
let fullPath;
|
||||
if (photo.path) {
|
||||
// Path is relative to events/active directory
|
||||
fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
} else {
|
||||
console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract dimensions using sharp
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
await knex('photos')
|
||||
.where('id', photo.id)
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
});
|
||||
updated++;
|
||||
|
||||
if (updated % 50 === 0) {
|
||||
console.log(`[Migration 064] Updated ${updated} photos...`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// This migration only adds data, no rollback needed
|
||||
// We don't want to null out dimensions on rollback as they're still valid
|
||||
console.log('[Migration 064] Rollback: No action needed (data-only migration)');
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Migration: Add header_style and hero_divider_style columns
|
||||
*
|
||||
* This migration decouples the hero header style from gallery layout,
|
||||
* allowing any combination of header style with any layout type.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('[Migration 065] Adding header_style and hero_divider_style columns');
|
||||
|
||||
// Check if columns already exist
|
||||
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
|
||||
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
|
||||
|
||||
if (!hasHeaderStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('header_style', 20).defaultTo('standard');
|
||||
});
|
||||
console.log('[Migration 065] Added header_style column');
|
||||
}
|
||||
|
||||
if (!hasDividerStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('hero_divider_style', 20).defaultTo('wave');
|
||||
});
|
||||
console.log('[Migration 065] Added hero_divider_style column');
|
||||
}
|
||||
|
||||
// Migrate existing events with hero layout in color_theme
|
||||
console.log('[Migration 065] Migrating existing hero layouts...');
|
||||
|
||||
const events = await knex('events')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Skip if color_theme is not JSON
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
// Check if this event uses hero layout
|
||||
if (theme.galleryLayout === 'hero') {
|
||||
// Migrate: set headerStyle to 'hero' and galleryLayout to 'grid'
|
||||
const updatedTheme = {
|
||||
...theme,
|
||||
headerStyle: 'hero',
|
||||
galleryLayout: 'grid',
|
||||
heroDividerStyle: theme.heroDividerStyle || 'wave'
|
||||
};
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(updatedTheme),
|
||||
header_style: 'hero',
|
||||
hero_divider_style: theme.heroDividerStyle || 'wave'
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid JSON in color_theme, skip
|
||||
console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`);
|
||||
console.log('[Migration 065] Completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('[Migration 065] Removing header_style and hero_divider_style columns');
|
||||
|
||||
// First, migrate any hero header styles back to hero layout
|
||||
const events = await knex('events')
|
||||
.where('header_style', 'hero')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
// Revert: set galleryLayout back to 'hero'
|
||||
const revertedTheme = {
|
||||
...theme,
|
||||
galleryLayout: 'hero'
|
||||
};
|
||||
|
||||
// Remove the new properties
|
||||
delete revertedTheme.headerStyle;
|
||||
delete revertedTheme.heroDividerStyle;
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(revertedTheme)
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the columns
|
||||
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
|
||||
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
|
||||
|
||||
if (hasHeaderStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('header_style');
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDividerStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('hero_divider_style');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Migration 065] Rollback completed');
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Migration: Add hero image anchor position and category-specific hero images
|
||||
*
|
||||
* Issue #162: Add hero_image_anchor column to events table for controlling
|
||||
* how hero images are cropped (top/center/bottom)
|
||||
*
|
||||
* Issue #163: Add hero_photo_id column to photo_categories table for
|
||||
* category-specific hero images
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add hero_image_anchor to events table (Issue #162)
|
||||
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
|
||||
if (!hasHeroAnchor) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
// Values: 'top', 'center', 'bottom' - defaults to 'center' for backward compatibility
|
||||
table.string('hero_image_anchor', 10).defaultTo('center');
|
||||
});
|
||||
console.log('Added hero_image_anchor column to events table');
|
||||
}
|
||||
|
||||
// Add hero_photo_id to photo_categories table (Issue #163)
|
||||
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
|
||||
if (!hasCategoryHero) {
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
});
|
||||
console.log('Added hero_photo_id column to photo_categories table');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove hero_image_anchor from events table
|
||||
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
|
||||
if (hasHeroAnchor) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('hero_image_anchor');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove hero_photo_id from photo_categories table
|
||||
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
|
||||
if (hasCategoryHero) {
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.dropColumn('hero_photo_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Migration: Expand hero_image_anchor column to support focal point percentages
|
||||
*
|
||||
* Changes string(10) to string(20) so values like "100% 100%" (9 chars) fit
|
||||
* with room to spare. Existing 'top', 'center', 'bottom' values are preserved.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
|
||||
if (!hasColumn) {
|
||||
// Column doesn't exist yet – nothing to expand
|
||||
return;
|
||||
}
|
||||
|
||||
// SQLite doesn't truly support ALTER COLUMN, but Knex handles the
|
||||
// rebuild-table strategy internally when we call alterTable.
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.string('hero_image_anchor', 20).defaultTo('center').alter();
|
||||
});
|
||||
console.log('Expanded hero_image_anchor column to string(20)');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
|
||||
if (!hasColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.string('hero_image_anchor', 10).defaultTo('center').alter();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
const DEFAULT_AI_AGENTS = [
|
||||
'GPTBot',
|
||||
'ChatGPT-User',
|
||||
'Google-Extended',
|
||||
'Claude-Web',
|
||||
'Anthropic-AI',
|
||||
'CCBot',
|
||||
'Bytespider',
|
||||
'FacebookBot',
|
||||
'Omgilibot',
|
||||
'Diffbot',
|
||||
'PetalBot',
|
||||
'Amazonbot',
|
||||
'PerplexityBot',
|
||||
'YouBot',
|
||||
'Applebot-Extended'
|
||||
];
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const defaults = [
|
||||
{ setting_key: 'seo_allow_indexing', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_block_ai_crawlers', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_block_social_bots', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_blocked_ai_agents', setting_value: JSON.stringify(DEFAULT_AI_AGENTS), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_custom_rules', setting_value: JSON.stringify([]), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_meta_noindex', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_meta_nofollow', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_meta_noai', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||
{ setting_key: 'seo_sitemap_url', setting_value: JSON.stringify(''), setting_type: 'seo' }
|
||||
];
|
||||
|
||||
for (const setting of defaults) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'seo_allow_indexing',
|
||||
'seo_block_ai_crawlers',
|
||||
'seo_block_social_bots',
|
||||
'seo_blocked_ai_agents',
|
||||
'seo_custom_rules',
|
||||
'seo_meta_noindex',
|
||||
'seo_meta_nofollow',
|
||||
'seo_meta_noai',
|
||||
'seo_sitemap_url'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Migration 069: Add hero image path to photos table
|
||||
* - photos.hero_path: path to hero-optimized image (1920x1080) for gallery headers
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 069_add_hero_path');
|
||||
|
||||
// photos.hero_path (nullable - path to hero-optimized image)
|
||||
await addColumnIfNotExists(knex, 'photos', 'hero_path', (table) => {
|
||||
table.string('hero_path', 512);
|
||||
});
|
||||
|
||||
console.log('Migration 069_add_hero_path completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 069_add_hero_path');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Migration 070: Add update notification settings and email template
|
||||
* - Settings for email notifications when new versions are available
|
||||
* - Email template for version update notifications
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 070_add_update_notification_settings');
|
||||
|
||||
// Add app_settings for update notifications
|
||||
const settings = [
|
||||
{
|
||||
setting_key: 'update_email_notifications_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'notifications'
|
||||
},
|
||||
{
|
||||
setting_key: 'update_email_recipients',
|
||||
setting_value: JSON.stringify(''), // Comma-separated emails, or empty for all admin emails
|
||||
setting_type: 'notifications'
|
||||
},
|
||||
{
|
||||
setting_key: 'last_notified_version',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'notifications'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email template already exists
|
||||
const existingTemplate = await knex('email_templates')
|
||||
.where('template_key', 'version_update_available')
|
||||
.first();
|
||||
|
||||
if (!existingTemplate) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'version_update_available',
|
||||
subject_en: 'PicPeak Update Available: Version {{new_version}}',
|
||||
subject_de: 'PicPeak Update verfugbar: Version {{new_version}}',
|
||||
body_html_en: `
|
||||
<h2>A New Version of PicPeak is Available</h2>
|
||||
|
||||
<p>Great news! A new version of PicPeak is available for your installation.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Current Version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>New Version:</strong> {{new_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
|
||||
</div>
|
||||
|
||||
<h3>What's New?</h3>
|
||||
<p>Check the release notes to see what's included in this update:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">View Release Notes</a>
|
||||
</div>
|
||||
|
||||
<h3>How to Update</h3>
|
||||
<p>To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.</p>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Reminder:</strong> Always backup your database before updating to ensure you can recover if anything goes wrong.</p>
|
||||
</div>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your PicPeak Installation</p>`,
|
||||
body_text_en: `A New Version of PicPeak is Available
|
||||
|
||||
Great news! A new version of PicPeak is available for your installation.
|
||||
|
||||
Current Version: {{current_version}}
|
||||
New Version: {{new_version}}
|
||||
Channel: {{channel}}
|
||||
|
||||
What's New?
|
||||
Check the release notes to see what's included in this update:
|
||||
{{release_notes_url}}
|
||||
|
||||
How to Update
|
||||
To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
|
||||
|
||||
REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong.
|
||||
|
||||
Best regards,
|
||||
Your PicPeak Installation`,
|
||||
body_html_de: `
|
||||
<h2>Eine neue Version von PicPeak ist verfugbar</h2>
|
||||
|
||||
<p>Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Aktuelle Version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Neue Version:</strong> {{new_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
|
||||
</div>
|
||||
|
||||
<h3>Was ist neu?</h3>
|
||||
<p>Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Versionshinweise anzeigen</a>
|
||||
</div>
|
||||
|
||||
<h3>So aktualisieren Sie</h3>
|
||||
<p>Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.</p>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Erinnerung:</strong> Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.</p>
|
||||
</div>
|
||||
|
||||
<p>Mit freundlichen Grussen,<br>
|
||||
Ihre PicPeak-Installation</p>`,
|
||||
body_text_de: `Eine neue Version von PicPeak ist verfugbar
|
||||
|
||||
Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
|
||||
|
||||
Aktuelle Version: {{current_version}}
|
||||
Neue Version: {{new_version}}
|
||||
Kanal: {{channel}}
|
||||
|
||||
Was ist neu?
|
||||
Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
|
||||
{{release_notes_url}}
|
||||
|
||||
So aktualisieren Sie
|
||||
Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
|
||||
|
||||
ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
|
||||
|
||||
Mit freundlichen Grussen,
|
||||
Ihre PicPeak-Installation`,
|
||||
variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url'])
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Migration 070_add_update_notification_settings completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 070_add_update_notification_settings');
|
||||
|
||||
// Remove settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'update_email_notifications_enabled',
|
||||
'update_email_recipients',
|
||||
'last_notified_version'
|
||||
])
|
||||
.del();
|
||||
|
||||
// Remove email template
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'version_update_available')
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Migration 071: Add captured_at column to photos table
|
||||
* - Stores the original capture date from EXIF metadata
|
||||
* - Enables sorting photos by capture date instead of upload date
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 071_add_captured_at');
|
||||
|
||||
// Add captured_at column to photos table
|
||||
await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => {
|
||||
table.datetime('captured_at').nullable();
|
||||
});
|
||||
|
||||
// Add index for sorting performance
|
||||
const indexExists = await knex.schema.hasIndex
|
||||
? await knex.schema.hasIndex('photos', 'idx_photos_captured_at')
|
||||
: false;
|
||||
|
||||
if (!indexExists) {
|
||||
// Use raw query for index creation with IF NOT EXISTS
|
||||
const client = knex.client.config.client;
|
||||
if (client === 'pg') {
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)');
|
||||
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
|
||||
// SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first
|
||||
const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'");
|
||||
if (existingIndexes.length === 0) {
|
||||
await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration 071_add_captured_at completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 071_add_captured_at');
|
||||
// Keep column for safe rollback (intentionally no-op)
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
exports.up = async function(knex) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('host_email', 255).nullable().alter();
|
||||
table.string('admin_email', 255).nullable().alter();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('host_email', 255).notNullable().defaultTo('').alter();
|
||||
table.string('admin_email', 255).notNullable().defaultTo('').alter();
|
||||
});
|
||||
};
|
||||
@@ -1,22 +1,27 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
// Check if photo_counter column already exists to make migration idempotent
|
||||
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
|
||||
|
||||
// Initialize counters based on existing photos
|
||||
const categories = await knex('photo_categories').select('id');
|
||||
|
||||
for (const category of categories) {
|
||||
const photoCount = await knex('photos')
|
||||
.where('category_id', category.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount && photoCount.count > 0) {
|
||||
await knex('photo_categories')
|
||||
.where('id', category.id)
|
||||
.update({ photo_counter: photoCount.count });
|
||||
if (!hasPhotoCounter) {
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
// Initialize counters based on existing photos
|
||||
const categories = await knex('photo_categories').select('id');
|
||||
|
||||
for (const category of categories) {
|
||||
const photoCount = await knex('photos')
|
||||
.where('category_id', category.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount && photoCount.count > 0) {
|
||||
await knex('photo_categories')
|
||||
.where('id', category.id)
|
||||
.update({ photo_counter: photoCount.count });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language-specific columns to email_templates
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
// Add English versions (rename existing columns for consistency)
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
|
||||
// Add German versions
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
// Check which columns already exist to make migration idempotent
|
||||
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
|
||||
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
|
||||
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
|
||||
|
||||
// Copy existing values to German columns as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
// Only rename columns if they haven't been renamed yet
|
||||
if (hasSubjectOriginal && !hasSubjectEn) {
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
});
|
||||
}
|
||||
|
||||
// Only add German columns if they don't exist
|
||||
if (!hasSubjectDe) {
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
|
||||
// Copy existing values to German columns as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
|
||||
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
|
||||
const migrationPath = path.join(__dirname, filepath);
|
||||
const migration = require(migrationPath);
|
||||
const filename = path.basename(filepath);
|
||||
|
||||
|
||||
if (migration.up) {
|
||||
console.log(`Running migration: ${filepath}`);
|
||||
|
||||
|
||||
// Run migration in a transaction if possible
|
||||
// IMPORTANT: Include the migrations table insert INSIDE the transaction
|
||||
// to ensure atomicity between schema changes and tracking
|
||||
if (db.client.config.client === 'pg') {
|
||||
await db.transaction(async (trx) => {
|
||||
await migration.up(trx);
|
||||
// Insert migration record inside transaction for atomicity
|
||||
await trx('migrations').insert({ filename });
|
||||
});
|
||||
} else {
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
}
|
||||
|
||||
await db('migrations').insert({ filename });
|
||||
|
||||
console.log(`Migration ${filepath} completed successfully`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if error is because schema already exists
|
||||
if (error.code === '42P07' || // PostgreSQL: relation already exists
|
||||
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
|
||||
// PostgreSQL error codes:
|
||||
// - 42P07: duplicate_table (relation already exists)
|
||||
// - 42701: duplicate_column (column already exists)
|
||||
// - 42710: duplicate_object (constraint, index, etc. already exists)
|
||||
// - 23505: unique_violation (migration record already exists)
|
||||
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
|
||||
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
|
||||
|
||||
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
|
||||
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
|
||||
await markMigrationAsApplied(path.basename(filepath));
|
||||
} else {
|
||||
|
||||
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
|
||||
const migrationPath = path.join(__dirname, filepath);
|
||||
const migration = require(migrationPath);
|
||||
const filename = path.basename(filepath);
|
||||
|
||||
|
||||
if (migration.up) {
|
||||
console.log(`Running migration: ${filepath}`);
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
|
||||
// Run migration in a transaction if PostgreSQL to ensure atomicity
|
||||
// between schema changes and migration tracking
|
||||
if (db.client.config.client === 'pg') {
|
||||
await db.transaction(async (trx) => {
|
||||
await migration.up(trx);
|
||||
await trx('migrations').insert({ filename });
|
||||
});
|
||||
} else {
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
}
|
||||
|
||||
console.log(`Migration ${filepath} completed`);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+836
-939
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.3.3",
|
||||
"version": "2.6.1",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrations/run-migrations.js",
|
||||
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||
"generate:watermarks": "node scripts/generate-watermarks.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
@@ -24,6 +25,7 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
@@ -64,7 +66,11 @@
|
||||
"tar-fs": "2.1.4"
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
"js-yaml": "^4.1.1",
|
||||
"fast-xml-parser": ">=5.3.8",
|
||||
"qs": ">=6.14.2",
|
||||
"tar": ">=7.5.8",
|
||||
"brace-expansion": ">=5.0.0",
|
||||
"minimatch": ">=9.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,28 +44,39 @@ async function createAdmin() {
|
||||
.orWhere('username', username)
|
||||
.first();
|
||||
|
||||
if (existingUser) {
|
||||
console.error(`Error: User with email "${email}" or username "${username}" already exists`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create admin user
|
||||
await db('admin_users').insert({
|
||||
username,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
if (existingUser) {
|
||||
// Update existing user's password
|
||||
await db('admin_users')
|
||||
.where('id', existingUser.id)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
console.log(`✅ Admin user created successfully!`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
|
||||
console.log(`✅ Admin user updated successfully!`);
|
||||
console.log(` Email: ${existingUser.email}`);
|
||||
console.log(` Username: ${existingUser.username}`);
|
||||
console.log(` Password has been reset to the provided value`);
|
||||
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
|
||||
} else {
|
||||
// Create new admin user
|
||||
await db('admin_users').insert({
|
||||
username,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
console.log(`✅ Admin user created successfully!`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to generate pre-watermarked versions for existing photos
|
||||
* This is a one-time migration script to populate watermarks for photos
|
||||
* that existed before the pre-generation feature was implemented.
|
||||
*
|
||||
* Usage: node scripts/generate-watermarks.js [eventId]
|
||||
*
|
||||
* Options:
|
||||
* eventId - Optional: Only generate watermarks for a specific event
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/generate-watermarks.js # Generate for all photos
|
||||
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { db } = require('../src/database/db');
|
||||
const watermarkService = require('../src/services/watermarkService');
|
||||
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
|
||||
|
||||
async function generateWatermarks(eventId = null) {
|
||||
try {
|
||||
console.log('='.repeat(60));
|
||||
console.log('PicPeak Watermark Generation Script');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Check if watermarking is enabled
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (!settings || !settings.enabled) {
|
||||
console.log('\nWatermarking is currently DISABLED in settings.');
|
||||
console.log('Enable watermarking in Admin > Branding settings first.');
|
||||
console.log('Exiting without generating watermarks.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('\nWatermark Settings:');
|
||||
console.log(` Enabled: ${settings.enabled}`);
|
||||
console.log(` Position: ${settings.position}`);
|
||||
console.log(` Opacity: ${settings.opacity}%`);
|
||||
console.log(` Size: ${settings.size}%`);
|
||||
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
|
||||
|
||||
// Build query
|
||||
let query = db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.whereNull('photos.watermark_path')
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.event_id',
|
||||
'events.event_name'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photos.event_id', eventId);
|
||||
console.log(`\nFiltering to event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
|
||||
if (photos.length === 0) {
|
||||
console.log('\nNo photos found without watermarks.');
|
||||
if (eventId) {
|
||||
console.log(`(Checked event ID: ${eventId})`);
|
||||
}
|
||||
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\nFound ${photos.length} photos without watermarks.`);
|
||||
|
||||
// Group by event for display
|
||||
const eventCounts = {};
|
||||
photos.forEach(p => {
|
||||
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('\nPhotos by event:');
|
||||
Object.entries(eventCounts).forEach(([name, count]) => {
|
||||
console.log(` ${name}: ${count} photos`);
|
||||
});
|
||||
|
||||
console.log('\nStarting watermark generation...\n');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process photos with progress display
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const progress = Math.round(((i + 1) / photos.length) * 100);
|
||||
|
||||
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
|
||||
|
||||
try {
|
||||
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.log(`\n Error: ${photo.filename} - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log('\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('Watermark Generation Complete');
|
||||
console.log('='.repeat(60));
|
||||
console.log(` Total processed: ${photos.length}`);
|
||||
console.log(` Successful: ${successCount}`);
|
||||
console.log(` Failed: ${failCount}`);
|
||||
console.log(` Duration: ${duration} seconds`);
|
||||
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.log('\nSome watermarks failed to generate. Check the errors above.');
|
||||
console.log('You can re-run this script to retry failed photos.');
|
||||
}
|
||||
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
} catch (error) {
|
||||
console.error('\nFatal error:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const eventId = args[0] ? parseInt(args[0], 10) : null;
|
||||
|
||||
if (args[0] && isNaN(eventId)) {
|
||||
console.error('Error: eventId must be a number');
|
||||
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateWatermarks(eventId)
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+79
-14
@@ -252,10 +252,29 @@ function renderBrandFooter(branding) {
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function buildSeoMetaTags(seoSettings) {
|
||||
const tags = [];
|
||||
const robotsDirectives = [];
|
||||
|
||||
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
|
||||
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
|
||||
|
||||
if (robotsDirectives.length > 0) {
|
||||
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
|
||||
}
|
||||
|
||||
if (seoSettings.seo_meta_noai) {
|
||||
tags.push('<meta name="robots" content="noai, noimageai" />');
|
||||
}
|
||||
|
||||
return tags.join('\n ');
|
||||
}
|
||||
|
||||
function buildPublicSiteDocument(payload) {
|
||||
const inlineStyles = composeInlineStyles(payload);
|
||||
const header = renderBrandHeader(payload.branding);
|
||||
const footer = renderBrandFooter(payload.branding);
|
||||
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -265,6 +284,7 @@ function buildPublicSiteDocument(payload) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
${seoMeta}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
@@ -296,6 +316,21 @@ async function handlePublicSiteRequest(req, res, next) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject SEO meta settings into payload
|
||||
try {
|
||||
const seoRows = await db('app_settings')
|
||||
.where('setting_type', 'seo')
|
||||
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
|
||||
.select('setting_key', 'setting_value');
|
||||
const seoSettings = {};
|
||||
for (const row of seoRows) {
|
||||
let val = row.setting_value;
|
||||
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
|
||||
seoSettings[row.setting_key] = val;
|
||||
}
|
||||
payload.seoSettings = seoSettings;
|
||||
} catch {}
|
||||
|
||||
const document = buildPublicSiteDocument(payload);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
@@ -324,8 +359,8 @@ async function initializeRateLimiters() {
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
@@ -351,8 +386,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
|
||||
|
||||
// Middleware to set CORS headers for static files
|
||||
const setCorsHeaders = (req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
const origin = req.headers.origin;
|
||||
const staticAllowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
staticAllowedOrigins.push(
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3000'
|
||||
);
|
||||
}
|
||||
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
next();
|
||||
};
|
||||
@@ -395,24 +445,37 @@ if (process.env.NODE_ENV === 'development') {
|
||||
});
|
||||
}
|
||||
|
||||
// robots.txt endpoint (dynamic, served from DB settings)
|
||||
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||
app.get('/robots.txt', async (req, res) => {
|
||||
try {
|
||||
const robotsTxt = await generateRobotsTxt();
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.status(200).send(robotsTxt);
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate robots.txt', { error: error.message });
|
||||
// Safe default for a private photo platform
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.status(200).send('User-agent: *\nDisallow: /\n');
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
// Check database connectivity
|
||||
await db.raw('SELECT 1');
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
timestamp: new Date().toISOString()
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -432,11 +495,13 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
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'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
|
||||
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
// Check if gallery has expired (only if expires_at is set)
|
||||
// Galleries with null expires_at never expire
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
|
||||
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
// Update a category
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required')
|
||||
body('name').notEmpty().withMessage('Category name is required'),
|
||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||
if (value === null || value === undefined) return true;
|
||||
return Number.isInteger(Number(value));
|
||||
}).withMessage('hero_photo_id must be an integer or null')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
const { name, hero_photo_id } = req.body;
|
||||
|
||||
const category = await db('photo_categories').where('id', id).first();
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
|
||||
const updateData = {
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim()
|
||||
};
|
||||
|
||||
// Update hero_photo_id if provided (including null to clear it)
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
|
||||
updateData.hero_photo_id = hero_photo_id || null;
|
||||
}
|
||||
|
||||
await db('photo_categories')
|
||||
.where('id', id)
|
||||
.update({
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim()
|
||||
});
|
||||
|
||||
.update(updateData);
|
||||
|
||||
const updated = await db('photo_categories').where('id', id).first();
|
||||
|
||||
|
||||
// Log activity
|
||||
await logActivity('category_updated',
|
||||
{ categoryName: name },
|
||||
{ categoryName: name, heroPhotoId: hero_photo_id },
|
||||
category.event_id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Set category hero photo (#163)
|
||||
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||
if (value === null || value === undefined) return true;
|
||||
return Number.isInteger(Number(value));
|
||||
}).withMessage('hero_photo_id must be an integer or null')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { hero_photo_id } = req.body;
|
||||
|
||||
const category = await db('photo_categories').where('id', id).first();
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
// If hero_photo_id is provided, verify it belongs to a photo in this category
|
||||
if (hero_photo_id) {
|
||||
const photo = await db('photos').where('id', hero_photo_id).first();
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
}
|
||||
|
||||
await db('photo_categories')
|
||||
.where('id', id)
|
||||
.update({ hero_photo_id: hero_photo_id || null });
|
||||
|
||||
const updated = await db('photo_categories').where('id', id).first();
|
||||
|
||||
// Log activity
|
||||
await logActivity('category_hero_updated',
|
||||
{ categoryName: category.name, heroPhotoId: hero_photo_id },
|
||||
category.event_id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category hero:', error);
|
||||
res.status(500).json({ error: 'Failed to update category hero' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a category
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Admin Event Types Routes
|
||||
* CRUD operations for managing customizable event types
|
||||
*
|
||||
* @module routes/adminEventTypes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /admin/event-types
|
||||
* Get all event types (for admin management)
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const eventTypes = await eventTypeService.getAllEventTypes({
|
||||
activeOnly: !includeInactive
|
||||
});
|
||||
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/active
|
||||
* Get only active event types (for dropdowns/selection)
|
||||
*/
|
||||
router.get('/active', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await eventTypeService.getActiveEventTypes();
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching active event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/:id
|
||||
* Get a single event type by ID
|
||||
*/
|
||||
router.get('/:id', adminAuth, requirePermission('settings.view'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
|
||||
|
||||
if (!eventType) {
|
||||
return res.status(404).json({ error: 'Event type not found' });
|
||||
}
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event type:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types
|
||||
* Create a new event type
|
||||
*/
|
||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().trim().withMessage('Name is required'),
|
||||
body('slug_prefix')
|
||||
.notEmpty()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = req.body;
|
||||
|
||||
const eventType = await eventTypeService.createEventType({
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_created',
|
||||
{ name, slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.status(201).json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error creating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to create event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/event-types/:id
|
||||
* Update an event type
|
||||
*/
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID'),
|
||||
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
|
||||
body('slug_prefix')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 }),
|
||||
body('is_active').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_updated',
|
||||
{ id, changes: Object.keys(updates) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error updating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to update event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /admin/event-types/:id
|
||||
* Delete an event type (only non-system types with no events)
|
||||
*/
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await eventTypeService.deleteEventType(parseInt(id));
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_deleted',
|
||||
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event type deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to delete event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types/reorder
|
||||
* Reorder event types by providing an array of IDs in the desired order
|
||||
*/
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { orderedIds } = req.body;
|
||||
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_types_reordered',
|
||||
{ newOrder: orderedIds },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ eventTypes, message: 'Event types reordered successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error reordering event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to reorder event types' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
@@ -16,7 +17,48 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for event logo uploads
|
||||
const eventLogoStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
@@ -25,14 +67,18 @@ const getEventFieldRequirements = async () => {
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
@@ -47,6 +93,8 @@ const getEventFieldRequirements = async () => {
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
@@ -55,7 +103,9 @@ const getEventFieldRequirements = async () => {
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -104,12 +154,18 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
@@ -143,7 +199,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -182,7 +247,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
css_template_id = null
|
||||
css_template_id = null,
|
||||
// Hero logo settings
|
||||
hero_logo_visible = true,
|
||||
hero_logo_size = 'medium',
|
||||
hero_logo_position = 'top',
|
||||
// Header style settings
|
||||
header_style = 'standard',
|
||||
hero_divider_style = 'wave',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center'
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -201,6 +275,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
if (fieldRequirements.require_admin_email && !admin_email) {
|
||||
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_event_date && !event_date) {
|
||||
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ errors: validationErrors });
|
||||
@@ -245,10 +322,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
|
||||
// Use event_date in slug if provided, otherwise use random suffix
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
@@ -256,7 +336,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -264,15 +344,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
// If expiration is not required, expires_at will be null (never expires)
|
||||
// If event_date is not provided, use current date as base for expiration
|
||||
let expires_at = null;
|
||||
if (fieldRequirements.require_expiration) {
|
||||
const baseDate = event_date || new Date().toISOString().split('T')[0];
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(baseDate);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -280,22 +365,42 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158).
|
||||
let effectiveHeaderStyle = header_style;
|
||||
let effectiveDividerStyle = hero_divider_style;
|
||||
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
|
||||
try {
|
||||
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
|
||||
const parsed = JSON.parse(color_theme);
|
||||
if (!req.body.header_style && parsed.headerStyle) {
|
||||
effectiveHeaderStyle = parsed.headerStyle;
|
||||
}
|
||||
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
|
||||
effectiveDividerStyle = parsed.heroDividerStyle;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// color_theme is not JSON – nothing to extract
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
event_date: event_date || null,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
host_name: customerName || null,
|
||||
host_email: customerEmail || null,
|
||||
admin_email: admin_email || null,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
@@ -305,7 +410,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null
|
||||
css_template_id: css_template_id || null,
|
||||
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
|
||||
hero_logo_size: hero_logo_size || 'medium',
|
||||
hero_logo_position: hero_logo_position || 'top',
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center'
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -335,28 +446,30 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue creation email
|
||||
// Queue creation email (only if there is a recipient)
|
||||
// Language detection is handled by email processor
|
||||
|
||||
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.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
|
||||
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 || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
@@ -367,7 +480,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -384,8 +497,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
const status = req.query.status || 'all';
|
||||
const sortBy = req.query.sortBy || 'created_at';
|
||||
const sortOrder = req.query.sortOrder || 'desc';
|
||||
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
|
||||
const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
|
||||
const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc';
|
||||
|
||||
// Build query
|
||||
let query = db('events');
|
||||
@@ -541,7 +655,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
body('admin_email').optional().isEmail(),
|
||||
body('is_active').optional().isBoolean(),
|
||||
body('expires_at').optional().isISO8601(),
|
||||
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
@@ -573,7 +687,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('overlay_protection').optional().isBoolean(),
|
||||
body('image_quality').optional().isInt({ min: 1, max: 100 }),
|
||||
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
body('password').optional().isString().custom((value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
@@ -582,7 +696,16 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -694,6 +817,43 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Enforce expires_at requirement based on app settings
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
|
||||
if (!updates.expires_at) {
|
||||
const fieldReqs = await getEventFieldRequirements();
|
||||
if (fieldReqs.require_expiration) {
|
||||
return res.status(400).json({ error: 'Expiration date is required.' });
|
||||
}
|
||||
updates.expires_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158). This ensures the
|
||||
// database columns stay in sync even if the frontend only sends the
|
||||
// serialised theme object.
|
||||
if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) {
|
||||
try {
|
||||
const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : '';
|
||||
if (themeStr.startsWith('{')) {
|
||||
const parsed = JSON.parse(themeStr);
|
||||
if (parsed.headerStyle) {
|
||||
updates.header_style = parsed.headerStyle;
|
||||
}
|
||||
if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) {
|
||||
updates.hero_divider_style = parsed.heroDividerStyle;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// color_theme is not JSON (e.g. preset name) – nothing to extract
|
||||
}
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -759,7 +919,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
@@ -768,6 +928,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete custom event logo if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(event.hero_logo_path);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
@@ -927,7 +1097,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
@@ -1094,4 +1265,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file provided' });
|
||||
}
|
||||
|
||||
// Delete old logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||
const logoPath = req.file.path;
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: logoUrl,
|
||||
hero_logo_path: logoPath
|
||||
});
|
||||
|
||||
await logActivity('event_logo_uploaded',
|
||||
{ eventName: event.event_name, filename: req.file.filename },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to upload event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: null,
|
||||
hero_logo_path: null
|
||||
});
|
||||
|
||||
await logActivity('event_logo_removed',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to delete event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
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');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
isRunning: false,
|
||||
lastResult: null
|
||||
};
|
||||
|
||||
// Repair photo dimensions (background job)
|
||||
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
if (repairProgress.isRunning) {
|
||||
return res.status(409).json({ error: 'Repair is already running' });
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
}
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
message: `Started repairing dimensions for ${photos.length} photos`,
|
||||
count: photos.length
|
||||
});
|
||||
|
||||
// Process in background
|
||||
repairProgress.isRunning = true;
|
||||
repairProgress.lastResult = null;
|
||||
|
||||
setImmediate(async () => {
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
logger.error('Sharp not available for dimension repair:', err.message);
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
successCount++;
|
||||
|
||||
if (successCount % 50 === 0) {
|
||||
logger.info(`Dimension repair progress: ${successCount} updated...`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: successCount, failed: errorCount };
|
||||
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error starting dimension repair:', error);
|
||||
res.status(500).json({ error: 'Failed to start dimension repair' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dimension repair status
|
||||
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const withDimensions = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.whereNotNull('width')
|
||||
.whereNotNull('height')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const total = Number(totalPhotos.count);
|
||||
const withDims = Number(withDimensions.count);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
withDimensions: withDims,
|
||||
withoutDimensions: total - withDims,
|
||||
isRunning: repairProgress.isRunning,
|
||||
lastResult: repairProgress.lastResult
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching dimension repair status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -84,8 +84,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
),
|
||||
eventId
|
||||
);
|
||||
|
||||
@@ -5,13 +5,15 @@ const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -46,47 +48,55 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create a multer instance that uses dynamically resolved allowed MIME types.
|
||||
// The allowed types are fetched from the database once per request (before multer
|
||||
// processes files) and attached to req.allowedMimeTypes so that the fileFilter
|
||||
// callback can read them synchronously.
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
parts: 10000, // Maximum number of parts (fields + files)
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
// req.allowedMimeTypes is populated by the middleware that runs before multer
|
||||
const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||
cb(new Error('Invalid file type. Check allowed file types in system settings.'));
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
// Middleware to resolve allowed MIME types from settings before multer runs
|
||||
const resolveAllowedTypes = async (req, res, next) => {
|
||||
try {
|
||||
req.allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve allowed MIME types:', error);
|
||||
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
// Dynamic content validator middleware that reads allowed types from req
|
||||
const validateUploadContent = async (req, res, next) => {
|
||||
const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const validator = createFileUploadValidator({
|
||||
allowedTypes,
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
return validator(req, res, next);
|
||||
};
|
||||
|
||||
// Request timeout middleware for uploads
|
||||
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
@@ -110,7 +120,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), async (req, res, next) => { // 10 minute timeout
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
@@ -175,8 +185,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
|
||||
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
|
||||
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
|
||||
|
||||
// Determine photo type and category name
|
||||
let photoType = 'individual'; // default
|
||||
@@ -251,16 +262,33 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
const finalPath = path.join(finalDestPath, newFilename);
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
|
||||
|
||||
|
||||
// Extract capture date from EXIF metadata
|
||||
let capturedAt = null;
|
||||
try {
|
||||
capturedAt = await extractCaptureDate(tempPath);
|
||||
} catch (exifError) {
|
||||
// Non-fatal - just log and continue without capture date
|
||||
console.log(`Could not extract EXIF date for ${file.originalname}`);
|
||||
}
|
||||
|
||||
// Determine media type
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname, // Preserve original filename for Lightroom export
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId, // Save the selected category
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
captured_at: capturedAt, // EXIF capture date (if available)
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -304,20 +332,67 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
// Generate thumbnail and extract metadata
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||
let thumbnailPath = null;
|
||||
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
if (isVideoFile) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
|
||||
if (photoId && result.metadata) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
duration: result.metadata.duration,
|
||||
video_codec: result.metadata.videoCodec,
|
||||
audio_codec: result.metadata.audioCodec,
|
||||
width: result.metadata.width,
|
||||
height: result.metadata.height
|
||||
});
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path and image dimensions
|
||||
if (photoId) {
|
||||
const updateData = {};
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(operation.finalPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update(updateData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking, images only)
|
||||
if (photoId && !isVideoFile) {
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
@@ -461,7 +536,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete pre-generated watermark if exists
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
|
||||
@@ -582,8 +662,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pre-generated watermark
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete from database
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
@@ -626,9 +711,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
const updateData = {};
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
@@ -698,20 +781,28 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
const { category_id, type, search, sort = 'date' } = req.query;
|
||||
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
|
||||
|
||||
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 type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
// Filter by category_id
|
||||
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
// Legacy type-based filtering
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
} else if (category_id === 'uncategorized') {
|
||||
// Filter for photos with no category assigned
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else {
|
||||
// Numeric category ID from photo_categories table
|
||||
const numericCategoryId = parseInt(category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
query = query.where({ 'photos.category_id': numericCategoryId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,6 +846,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
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}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
@@ -763,6 +855,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.pc_slug || photo.type,
|
||||
media_type: photo.media_type || 'image',
|
||||
mime_type: photo.mime_type || null,
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
duration: photo.duration || null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
|
||||
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -100,19 +102,31 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
@@ -127,23 +141,35 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', type)
|
||||
.select('*');
|
||||
|
||||
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
@@ -196,6 +222,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
hide_powered_by
|
||||
} = req.body;
|
||||
|
||||
// Get current watermark settings hash for change detection
|
||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||
|
||||
const brandingSettings = {
|
||||
company_name,
|
||||
company_tagline,
|
||||
@@ -306,7 +335,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
// Check if watermark settings changed and trigger regeneration
|
||||
const newSettingsHash = await watermarkService.getSettingsHash();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (oldSettingsHash !== newSettingsHash) {
|
||||
// Clear watermark cache
|
||||
watermarkService.clearCache();
|
||||
|
||||
// Check if watermarking is now enabled or settings changed
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
// Start background regeneration of all watermarks
|
||||
console.log('Watermark settings changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
} else {
|
||||
// Watermarking was disabled, clear all pre-generated watermarks
|
||||
console.log('Watermarking disabled, clearing pre-generated watermarks');
|
||||
watermarkGeneratorService.clearAllWatermarks()
|
||||
.catch(err => console.error('Failed to clear watermarks:', err));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Branding settings updated successfully',
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
@@ -441,9 +503,27 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
// Trigger watermark regeneration since the logo changed
|
||||
watermarkService.clearCache();
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
console.log('Watermark logo changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Watermark logo uploaded successfully',
|
||||
watermarkLogoUrl: publicPath
|
||||
watermarkLogoUrl: publicPath,
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Watermark logo upload error:', error);
|
||||
@@ -663,6 +743,70 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
}
|
||||
});
|
||||
|
||||
// Update SEO settings
|
||||
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
// Validate seo_blocked_ai_agents is an array of strings
|
||||
if (settings.seo_blocked_ai_agents !== undefined) {
|
||||
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
|
||||
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
|
||||
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seo_custom_rules structure
|
||||
if (settings.seo_custom_rules !== undefined) {
|
||||
if (!Array.isArray(settings.seo_custom_rules)) {
|
||||
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
|
||||
}
|
||||
for (const rule of settings.seo_custom_rules) {
|
||||
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
|
||||
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
|
||||
}
|
||||
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
|
||||
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'seo',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear robots.txt cache
|
||||
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
|
||||
clearRobotsTxtCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'seo_settings_updated',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||
});
|
||||
|
||||
res.json({ message: 'SEO settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('SEO settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update SEO settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,12 @@ const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||
const {
|
||||
checkAndNotifyUpdates,
|
||||
sendUpdateNotificationNow,
|
||||
getUpdateNotificationSettings
|
||||
} = require('../services/updateNotificationService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -65,6 +71,47 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
|
||||
}
|
||||
});
|
||||
|
||||
// Get update instructions for current environment
|
||||
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Check if update checking is enabled
|
||||
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
||||
|
||||
if (!updateCheckEnabled) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
message: 'Update checking is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
const env = await detectEnvironment();
|
||||
const updateInfo = await checkForUpdates();
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
return res.json({
|
||||
updateAvailable: false,
|
||||
currentVersion: updateInfo.current,
|
||||
message: 'You are running the latest version'
|
||||
});
|
||||
}
|
||||
|
||||
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
|
||||
|
||||
res.json({
|
||||
updateAvailable: true,
|
||||
currentVersion: updateInfo.current,
|
||||
targetVersion: updateInfo.latest.forChannel,
|
||||
channel: updateInfo.channel,
|
||||
environment: env,
|
||||
instructions,
|
||||
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error generating update instructions:', error);
|
||||
res.status(500).json({ error: 'Failed to generate update instructions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -262,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
|
||||
}
|
||||
});
|
||||
|
||||
// Get update notification settings
|
||||
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching update notification settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch update notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification settings
|
||||
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { enabled, recipients } = req.body;
|
||||
|
||||
if (typeof enabled !== 'undefined') {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'update_email_notifications_enabled')
|
||||
.update({
|
||||
setting_value: JSON.stringify(enabled === true),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof recipients !== 'undefined') {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'update_email_recipients')
|
||||
.update({
|
||||
setting_value: JSON.stringify(recipients || ''),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSettings = await getUpdateNotificationSettings();
|
||||
res.json({ success: true, settings: updatedSettings });
|
||||
} catch (error) {
|
||||
logger.error('Error updating notification settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually trigger update notification email
|
||||
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const result = await sendUpdateNotificationNow();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error sending update notification:', error);
|
||||
res.status(500).json({ error: 'Failed to send update notification' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check and send update notifications (called on admin login or periodically)
|
||||
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const result = await checkAndNotifyUpdates();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error checking for update notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to check for update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -10,27 +10,33 @@ const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Parse JSON-encoded setting values
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
try { return JSON.parse(value); } catch (e) { return value; }
|
||||
}
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('key', 'value', 'description');
|
||||
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.key] = {
|
||||
value: s.value,
|
||||
description: s.description
|
||||
const parsed = parseSettingValue(s.setting_value);
|
||||
settingsMap[s.setting_key] = {
|
||||
value: String(parsed ?? '')
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
settings: settingsMap,
|
||||
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
|
||||
@@ -66,17 +72,17 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
|
||||
|
||||
// Update settings
|
||||
const updates = [];
|
||||
if (width) updates.push({ key: 'thumbnail_width', value: width.toString() });
|
||||
if (height) updates.push({ key: 'thumbnail_height', value: height.toString() });
|
||||
if (fit) updates.push({ key: 'thumbnail_fit', value: fit });
|
||||
if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() });
|
||||
if (format) updates.push({ key: 'thumbnail_format', value: format });
|
||||
|
||||
if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width });
|
||||
if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height });
|
||||
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
|
||||
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
|
||||
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
|
||||
|
||||
for (const update of updates) {
|
||||
await db('app_settings')
|
||||
.where('key', update.key)
|
||||
.where('setting_key', update.setting_key)
|
||||
.update({
|
||||
value: update.value,
|
||||
setting_value: update.setting_value,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
|
||||
+285
-42
@@ -5,6 +5,7 @@ const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -12,10 +13,10 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
@@ -107,13 +108,21 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'allow_user_uploads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering'
|
||||
'use_canvas_rendering',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -151,15 +160,23 @@ router.get('/:slug/info', async (req, res) => {
|
||||
event_date: event.event_date,
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -170,14 +187,34 @@ router.get('/:slug/info', async (req, res) => {
|
||||
// Get all photos
|
||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
// Get filter and sort parameters from query
|
||||
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const wmVersion = watermarkSettings?.enabled
|
||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '';
|
||||
|
||||
// Build the query with sorting
|
||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
.select('photos.*');
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
|
||||
} else if (sort === 'filename') {
|
||||
photosQuery = photosQuery.orderBy('photos.filename', sortOrder);
|
||||
} else {
|
||||
// Default: sort by upload date
|
||||
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
let photos = await photosQuery;
|
||||
|
||||
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||
if (filter) {
|
||||
@@ -273,20 +310,36 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
// Get distinct photo types for this event
|
||||
const categoryResults = await db('photos')
|
||||
// Get actual categories used by photos in this event
|
||||
// This includes both global categories and event-specific ones
|
||||
const usedCategoryIds = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.select('type')
|
||||
.distinct('type')
|
||||
.orderBy('type', 'asc');
|
||||
|
||||
// Convert types to category-like objects
|
||||
const categories = categoryResults.map(result => ({
|
||||
id: result.type,
|
||||
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
slug: result.type,
|
||||
is_global: false
|
||||
}));
|
||||
.whereNotNull('category_id')
|
||||
.distinct('category_id')
|
||||
.pluck('category_id');
|
||||
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
is_global: cat.is_global,
|
||||
hero_photo_id: cat.hero_photo_id || null
|
||||
}));
|
||||
}
|
||||
|
||||
// Build a map for quick category lookup
|
||||
const categoryMap = {};
|
||||
categories.forEach(cat => {
|
||||
categoryMap[cat.id] = cat;
|
||||
});
|
||||
|
||||
// Log view
|
||||
await db('access_logs').insert({
|
||||
@@ -317,33 +370,48 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
expires_at: req.event.expires_at,
|
||||
hero_photo_id: req.event.hero_photo_id,
|
||||
allow_downloads: req.event.allow_downloads !== false,
|
||||
allow_user_uploads: req.event.allow_user_uploads === true,
|
||||
disable_right_click: req.event.disable_right_click === true,
|
||||
watermark_downloads: req.event.watermark_downloads === true,
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
hero_logo_url: req.event.hero_logo_url || null,
|
||||
header_style: req.event.header_style || 'standard',
|
||||
hero_divider_style: req.event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
// Add watermark version to URLs for cache busting when settings change
|
||||
const wmQuery = wmVersion ? `?${wmVersion}` : '';
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: photoUrl,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
||||
// Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
category_id: photo.category_id || null,
|
||||
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
|
||||
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
@@ -699,8 +767,34 @@ router.get('/:slug/photo/:photoId',
|
||||
|
||||
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const fs = require('fs');
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Verify file exists before attempting to serve
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error('Photo file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: filePath,
|
||||
photoPath: photo.path
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
@@ -712,14 +806,13 @@ router.get('/:slug/photo/:photoId',
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
@@ -754,13 +847,52 @@ router.get('/:slug/photo/:photoId',
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, modification time, and watermark settings
|
||||
// This ensures cache invalidation when watermark settings change
|
||||
const stat = fs.statSync(filePath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
// Try to serve pre-generated watermarked file for instant loading
|
||||
if (photo.watermark_path) {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
try {
|
||||
// Check if pre-generated watermark file exists
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
return res.sendFile(watermarkFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// File doesn't exist or error, fall through to on-the-fly generation
|
||||
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
|
||||
// Also queue regeneration for next time
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Queue watermark generation in background for next request
|
||||
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
@@ -769,6 +901,7 @@ router.get('/:slug/photo/:photoId',
|
||||
// Send original file with basic protection headers
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
@@ -820,18 +953,32 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(thumbPath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true'
|
||||
'X-Protected-Thumbnail': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark to thumbnail
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
|
||||
@@ -851,6 +998,92 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
}
|
||||
);
|
||||
|
||||
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||
router.get('/:slug/hero/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// For videos, redirect to the regular photo endpoint
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
// Ensure hero image exists and is valid, regenerate if needed
|
||||
const heroPath = await ensureHeroImage(photo);
|
||||
|
||||
if (!heroPath) {
|
||||
// If hero generation fails, fall back to original photo
|
||||
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
const heroFullPath = path.join(getStoragePath(), heroPath);
|
||||
const fs = require('fs');
|
||||
|
||||
// Verify file exists before attempting to serve
|
||||
if (!fs.existsSync(heroFullPath)) {
|
||||
logger.error('Hero image file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: heroFullPath
|
||||
});
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
// Get file stats for ETag
|
||||
const stat = fs.statSync(heroFullPath);
|
||||
const etag = `"hero-${photoId}-${stat.mtime.getTime()}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Check if watermarks should be applied
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Hero-Image': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark to hero image
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(heroFullPath, watermarkSettings);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send hero image without watermark
|
||||
res.sendFile(path.resolve(heroFullPath));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving hero image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
// Fall back to original photo on any error
|
||||
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get feedback settings for gallery
|
||||
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
@@ -936,6 +1169,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const { getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Resolve allowed MIME types from settings
|
||||
let allowedMimeTypes;
|
||||
try {
|
||||
allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch {
|
||||
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
@@ -943,8 +1187,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
files: 10 // Max 10 files at once
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
|
||||
@@ -12,7 +12,8 @@ router.get('/', async (req, res) => {
|
||||
.where(function() {
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%')
|
||||
.orWhere('setting_key', 'like', 'event_require_%');
|
||||
.orWhere('setting_key', 'like', 'event_require_%')
|
||||
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
@@ -74,7 +75,15 @@ router.get('/', async (req, res) => {
|
||||
// Event field requirements
|
||||
event_require_customer_name: settingsObject.event_require_customer_name !== false,
|
||||
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
||||
event_require_event_date: settingsObject.event_require_event_date !== false,
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||
// Upload settings (safe to expose - needed for client-side validation)
|
||||
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
|
||||
// SEO meta tag flags (safe to expose - these are intended for crawlers)
|
||||
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
|
||||
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
|
||||
seo_meta_noai: settingsObject.seo_meta_noai === true
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -437,7 +437,7 @@ async function performLocalBackup(config, files) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRsyncCommand(config) {
|
||||
function buildRsyncArgs(config) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
@@ -446,20 +446,21 @@ function buildRsyncCommand(config) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
const options = ['-avz', '--delete', '--stats'];
|
||||
const args = ['-avz', '--delete', '--stats'];
|
||||
if (config.backup_rsync_ssh_key) {
|
||||
options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`);
|
||||
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
|
||||
}
|
||||
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`));
|
||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = config.backup_rsync_user
|
||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||
: `${host}:${remotePath}`;
|
||||
|
||||
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
|
||||
args.push(source, destination);
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseRsyncStats(output) {
|
||||
@@ -479,9 +480,9 @@ function parseRsyncStats(output) {
|
||||
}
|
||||
|
||||
async function performRsyncBackup(config, files) {
|
||||
const command = buildRsyncCommand(config);
|
||||
const execAsync = getExecAsync();
|
||||
const { stdout } = await execAsync(command);
|
||||
const { spawnAsync } = require('../utils/safeExec');
|
||||
const rsyncArgs = buildRsyncArgs(config);
|
||||
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||
const stats = parseRsyncStats(stdout);
|
||||
|
||||
const backedUpFiles = files.map(file => file.relativePath);
|
||||
@@ -503,8 +504,7 @@ async function performRsyncBackup(config, files) {
|
||||
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
|
||||
backedUpSize: totalSize,
|
||||
backedUpFiles,
|
||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
|
||||
rsyncCommand: command
|
||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const crypto = require('crypto');
|
||||
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
@@ -163,10 +161,10 @@ class DatabaseBackupService {
|
||||
|
||||
try {
|
||||
// Use SQLite's backup API for consistency
|
||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
|
||||
|
||||
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
|
||||
|
||||
// Verify the backup
|
||||
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
|
||||
const verifyResult = await spawnAsync('sqlite3', [tempPath, 'PRAGMA integrity_check']);
|
||||
if (!verifyResult.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
@@ -192,14 +190,6 @@ class DatabaseBackupService {
|
||||
async createPostgreSQLBackup(outputPath, options = {}) {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
|
||||
// Build connection string with proper escaping
|
||||
const connectionParts = [
|
||||
`host=${host}`,
|
||||
`port=${port}`,
|
||||
`dbname=${database}`,
|
||||
`user=${user}`
|
||||
];
|
||||
|
||||
// Set PGPASSWORD environment variable for security
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
@@ -227,14 +217,17 @@ class DatabaseBackupService {
|
||||
pgDumpOptions.push('--compress=6');
|
||||
}
|
||||
|
||||
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
|
||||
|
||||
const pgDumpArgs = [
|
||||
...pgDumpOptions,
|
||||
'-h', host,
|
||||
'-p', String(port),
|
||||
'-U', user,
|
||||
'-d', database
|
||||
];
|
||||
|
||||
try {
|
||||
const { stderr } = await execAsync(command, {
|
||||
env,
|
||||
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
|
||||
});
|
||||
|
||||
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
|
||||
|
||||
// pg_dump writes progress to stderr, not an error
|
||||
if (stderr && !stderr.includes('dump complete')) {
|
||||
logger.warn('pg_dump warnings:', stderr);
|
||||
@@ -261,7 +254,7 @@ class DatabaseBackupService {
|
||||
try {
|
||||
if (this.dbType === 'sqlite') {
|
||||
// For SQLite, we can directly check integrity
|
||||
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
|
||||
const result = await spawnAsync('sqlite3', [backupPath, 'PRAGMA integrity_check']);
|
||||
if (!result.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Environment Detection Service
|
||||
* Detects the deployment environment and generates update instructions accordingly.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Detect the current deployment environment
|
||||
* @returns {Object} Environment information
|
||||
*/
|
||||
async function detectEnvironment() {
|
||||
// Check for Docker environment
|
||||
const isDocker = fs.existsSync('/.dockerenv') ||
|
||||
process.env.DOCKER_CONTAINER === 'true';
|
||||
|
||||
// Determine project root (services -> src -> backend)
|
||||
const projectRoot = path.join(__dirname, '../../..');
|
||||
|
||||
// Check for git repository
|
||||
const isGit = fs.existsSync(path.join(projectRoot, '.git'));
|
||||
|
||||
// Check for docker-compose files
|
||||
const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) ||
|
||||
fs.existsSync(path.join(projectRoot, 'docker-compose.yaml'));
|
||||
|
||||
// Get app version
|
||||
let appVersion = '0.0.0';
|
||||
try {
|
||||
const packagePath = path.join(__dirname, '../../package.json');
|
||||
const packageContent = fs.readFileSync(packagePath, 'utf8');
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
appVersion = packageJson.version || '0.0.0';
|
||||
} catch (err) {
|
||||
logger.warn('Could not read package.json for version:', err.message);
|
||||
}
|
||||
|
||||
// Determine environment type
|
||||
let type;
|
||||
if (isDocker) {
|
||||
type = 'docker';
|
||||
} else if (isGit) {
|
||||
type = 'git';
|
||||
} else {
|
||||
type = 'standalone';
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
isDocker,
|
||||
isGit,
|
||||
hasDockerCompose,
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
appVersion
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate environment-specific update instructions
|
||||
* @param {Object} env - Environment info from detectEnvironment()
|
||||
* @param {string} targetVersion - Target version to update to
|
||||
* @returns {Object} Update instructions with pre-checks, steps, and post-checks
|
||||
*/
|
||||
function generateUpdateInstructions(env, targetVersion) {
|
||||
const instructions = {
|
||||
preChecks: [
|
||||
{
|
||||
id: 'backup',
|
||||
text: 'I have backed up my database',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: 'no-uploads',
|
||||
text: 'No uploads are currently in progress',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: 'downtime-aware',
|
||||
text: 'I understand the application will restart during update',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
steps: [],
|
||||
postChecks: [
|
||||
'Verify the application starts correctly',
|
||||
'Check the version in Admin -> System',
|
||||
'Review release notes for any breaking changes or required actions'
|
||||
],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
if (env.isDocker) {
|
||||
instructions.environmentName = 'Docker';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Pull latest images',
|
||||
command: 'docker compose pull',
|
||||
note: 'Downloads the new version images'
|
||||
},
|
||||
{
|
||||
description: 'Recreate containers with new images',
|
||||
command: 'docker compose up -d',
|
||||
note: 'Restarts containers with new version'
|
||||
},
|
||||
{
|
||||
description: 'Watch logs for startup (optional)',
|
||||
command: 'docker compose logs -f backend',
|
||||
note: 'Press Ctrl+C to exit logs',
|
||||
optional: true
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
|
||||
} else if (env.isGit) {
|
||||
instructions.environmentName = 'Git (Development)';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Fetch latest changes',
|
||||
command: 'git fetch origin',
|
||||
note: 'Downloads references from remote'
|
||||
},
|
||||
{
|
||||
description: 'Switch to new version tag',
|
||||
command: `git checkout v${targetVersion}`,
|
||||
note: 'Switches to the release version'
|
||||
},
|
||||
{
|
||||
description: 'Install backend dependencies',
|
||||
command: 'cd backend && npm install',
|
||||
note: 'Updates npm packages'
|
||||
},
|
||||
{
|
||||
description: 'Build frontend',
|
||||
command: 'cd frontend && npm install && npm run build',
|
||||
note: 'Compiles the frontend application'
|
||||
},
|
||||
{
|
||||
description: 'Run database migrations',
|
||||
command: 'cd backend && npm run migrate',
|
||||
note: 'Updates database schema'
|
||||
},
|
||||
{
|
||||
description: 'Restart application',
|
||||
command: '# Restart your application (pm2, systemd, etc.)',
|
||||
note: 'Method depends on your setup - e.g., pm2 restart picpeak'
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)');
|
||||
} else {
|
||||
instructions.environmentName = 'Standalone';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Download release archive',
|
||||
command: `# Download v${targetVersion} from GitHub Releases`,
|
||||
note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}`
|
||||
},
|
||||
{
|
||||
description: 'Backup current installation',
|
||||
command: '# Create backup of current files',
|
||||
note: 'Keep a copy of your current installation'
|
||||
},
|
||||
{
|
||||
description: 'Extract and replace application files',
|
||||
command: '# Extract release archive to installation directory',
|
||||
note: 'Preserve your .env file and storage directory'
|
||||
},
|
||||
{
|
||||
description: 'Install dependencies',
|
||||
command: 'cd backend && npm install --production',
|
||||
note: 'Updates npm packages'
|
||||
},
|
||||
{
|
||||
description: 'Run database migrations',
|
||||
command: 'cd backend && npm run migrate',
|
||||
note: 'Updates database schema'
|
||||
},
|
||||
{
|
||||
description: 'Restart application',
|
||||
command: '# Restart your application service',
|
||||
note: 'Method depends on your setup'
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating');
|
||||
instructions.warnings.push('Consider creating a full backup before updating');
|
||||
}
|
||||
|
||||
return instructions;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectEnvironment,
|
||||
generateUpdateInstructions
|
||||
};
|
||||
@@ -14,8 +14,9 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('./eventTypeService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Cache for schema detection
|
||||
let customerColumnCache = null;
|
||||
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
|
||||
|
||||
/**
|
||||
* Generate a unique slug for an event
|
||||
* @param {string} eventType
|
||||
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
|
||||
* @param {string} eventName
|
||||
* @param {string} eventDate
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
||||
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
|
||||
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
|
||||
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
|
||||
|
||||
const processedName = eventName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Event Type Service Layer
|
||||
* Handles all event type-related business logic
|
||||
*
|
||||
* @module services/eventTypeService
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
/**
|
||||
* Get all event types
|
||||
* @param {Object} options - Filter options
|
||||
* @param {boolean} options.activeOnly - Only return active types
|
||||
* @returns {Promise<Array>} - Array of event types
|
||||
*/
|
||||
const getAllEventTypes = async (options = {}) => {
|
||||
const { activeOnly = false } = options;
|
||||
|
||||
let query = db('event_types').select('*');
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
}
|
||||
|
||||
const types = await query.orderBy('display_order', 'asc');
|
||||
return types;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active event types for dropdown/selection
|
||||
* @returns {Promise<Array>} - Array of active event types
|
||||
*/
|
||||
const getActiveEventTypes = async () => {
|
||||
return getAllEventTypes({ activeOnly: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by ID
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeById = async (id) => {
|
||||
const eventType = await db('event_types').where('id', id).first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by slug prefix
|
||||
* @param {string} slugPrefix - The slug prefix
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||
const eventType = await db('event_types')
|
||||
.where('slug_prefix', slugPrefix.toLowerCase())
|
||||
.first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
|
||||
* @param {string} slugPrefix - The slug prefix to validate
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const isValidEventType = async (slugPrefix) => {
|
||||
const normalized = slugPrefix.toLowerCase();
|
||||
|
||||
// Check in database
|
||||
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||
if (eventType && eventType.is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||
return legacyTypes.includes(normalized);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all valid slug prefixes (for validation)
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
const getValidSlugPrefixes = async () => {
|
||||
const types = await db('event_types')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.select('slug_prefix');
|
||||
|
||||
return types.map(t => t.slug_prefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new event type
|
||||
* @param {Object} eventTypeData - Event type data
|
||||
* @returns {Promise<Object>} - Created event type
|
||||
*/
|
||||
const createEventType = async (eventTypeData) => {
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = eventTypeData;
|
||||
|
||||
// Normalize slug_prefix
|
||||
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate slug_prefix
|
||||
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get max display order if not provided
|
||||
let finalDisplayOrder = display_order;
|
||||
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
|
||||
const maxOrder = await db('event_types').max('display_order as max').first();
|
||||
finalDisplayOrder = (maxOrder?.max || 0) + 1;
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
name,
|
||||
slug_prefix: normalizedSlugPrefix,
|
||||
emoji: emoji || '📷',
|
||||
theme_preset: theme_preset || 'default',
|
||||
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
||||
display_order: finalDisplayOrder,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const insertResult = await db('event_types').insert(insertData).returning('id');
|
||||
const eventTypeId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
return getEventTypeById(eventTypeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Object>} - Updated event type
|
||||
*/
|
||||
const updateEventType = async (id, updates) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData = {};
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name;
|
||||
}
|
||||
|
||||
if (updates.slug_prefix !== undefined) {
|
||||
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate (excluding current)
|
||||
const existing = await db('event_types')
|
||||
.where('slug_prefix', normalizedSlugPrefix)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateData.slug_prefix = normalizedSlugPrefix;
|
||||
}
|
||||
|
||||
if (updates.emoji !== undefined) {
|
||||
updateData.emoji = updates.emoji;
|
||||
}
|
||||
|
||||
if (updates.theme_preset !== undefined) {
|
||||
updateData.theme_preset = updates.theme_preset;
|
||||
}
|
||||
|
||||
if (updates.theme_config !== undefined) {
|
||||
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
||||
}
|
||||
|
||||
if (updates.display_order !== undefined) {
|
||||
updateData.display_order = updates.display_order;
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
updateData.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
await db('event_types').where('id', id).update(updateData);
|
||||
|
||||
return getEventTypeById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const deleteEventType = async (id) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types
|
||||
if (eventType.is_system) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
const eventsUsingType = await db('events')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db('event_types').where('id', id).del();
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder event types
|
||||
* @param {Array} orderedIds - Array of IDs in new order
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
const reorderEventTypes = async (orderedIds) => {
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await trx('event_types')
|
||||
.where('id', orderedIds[i])
|
||||
.update({ display_order: i + 1, updated_at: new Date() });
|
||||
}
|
||||
});
|
||||
|
||||
return getAllEventTypes();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get event type info for slug generation
|
||||
* Returns the slug_prefix to use for a given event type identifier
|
||||
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
|
||||
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
|
||||
*/
|
||||
const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||
// Try to find by slug_prefix first
|
||||
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
|
||||
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
// Try by ID if numeric
|
||||
if (!isNaN(eventTypeIdentifier)) {
|
||||
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for legacy types - return a compatible object
|
||||
const legacyDefaults = {
|
||||
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
|
||||
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
|
||||
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
|
||||
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
|
||||
};
|
||||
|
||||
const normalized = eventTypeIdentifier.toLowerCase();
|
||||
if (legacyDefaults[normalized]) {
|
||||
return legacyDefaults[normalized];
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllEventTypes,
|
||||
getActiveEventTypes,
|
||||
getEventTypeById,
|
||||
getEventTypeBySlugPrefix,
|
||||
isValidEventType,
|
||||
getValidSlugPrefixes,
|
||||
createEventType,
|
||||
updateEventType,
|
||||
deleteEventType,
|
||||
reorderEventTypes,
|
||||
getEventTypeForSlug
|
||||
};
|
||||
@@ -21,9 +21,11 @@ async function checkExpirations() {
|
||||
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
|
||||
|
||||
// Check for events needing warning emails
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
@@ -40,9 +42,11 @@ async function checkExpirations() {
|
||||
}
|
||||
|
||||
// Check for expired events
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const sharp = require('sharp');
|
||||
const exifr = require('exifr');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
@@ -119,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
failOnError: false // Don't fail on minor issues
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
// Apply resize with configured settings
|
||||
// For square thumbnails with 'cover' fit, we crop to center
|
||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||
@@ -287,4 +291,220 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
|
||||
// Hero image settings - optimized for large displays
|
||||
const DEFAULT_HERO_WIDTH = 1920;
|
||||
const DEFAULT_HERO_HEIGHT = 1080;
|
||||
const DEFAULT_HERO_QUALITY = 85;
|
||||
const DEFAULT_HERO_FORMAT = 'jpeg';
|
||||
|
||||
const getHeroPath = () => path.join(getStoragePath(), 'heroes');
|
||||
|
||||
/**
|
||||
* Generate a hero-optimized image for gallery headers
|
||||
* Outputs a 1920x1080 image suitable for full-width hero sections
|
||||
*/
|
||||
async function generateHeroImage(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const heroFilename = `hero_${filename}`;
|
||||
const heroDir = getHeroPath();
|
||||
const heroPath = path.join(heroDir, heroFilename);
|
||||
|
||||
// Ensure hero directory exists
|
||||
await fs.mkdir(heroDir, { recursive: true });
|
||||
|
||||
// Check if we need to regenerate
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(heroPath);
|
||||
logger.info(`Deleted existing hero image: ${heroPath}`);
|
||||
} catch (err) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the source image is complete and valid
|
||||
const metadata = await sharp(imagePath).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image metadata - file may be incomplete');
|
||||
}
|
||||
|
||||
// Calculate dimensions to maintain aspect ratio while fitting within hero bounds
|
||||
const heroWidth = options.width || DEFAULT_HERO_WIDTH;
|
||||
const heroHeight = options.height || DEFAULT_HERO_HEIGHT;
|
||||
const quality = options.quality || DEFAULT_HERO_QUALITY;
|
||||
|
||||
// Create sharp instance with memory-efficient settings
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689,
|
||||
sequentialRead: true,
|
||||
failOnError: false
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
// Resize to fit hero dimensions while maintaining aspect ratio
|
||||
// Use 'cover' to fill the hero area (crops if needed)
|
||||
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
||||
withoutEnlargement: false, // Allow upscaling for small images
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
});
|
||||
|
||||
// Apply JPEG format with high quality
|
||||
sharpInstance = sharpInstance.jpeg({
|
||||
quality: quality,
|
||||
progressive: true,
|
||||
mozjpeg: true
|
||||
});
|
||||
|
||||
// Save the hero image
|
||||
await sharpInstance.toFile(heroPath);
|
||||
|
||||
// Verify the hero image was created successfully
|
||||
const stats = await fs.stat(heroPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Generated hero image is empty');
|
||||
}
|
||||
|
||||
logger.info(`Generated hero image for ${filename}: ${heroPath}`);
|
||||
return path.relative(getStoragePath(), heroPath);
|
||||
} catch (error) {
|
||||
const msg = (error && error.message) ? error.message : String(error);
|
||||
logger.error(`Failed to generate hero image for ${filename}: ${msg}`);
|
||||
|
||||
// Clean up any partially created file
|
||||
try {
|
||||
await fs.unlink(heroPath);
|
||||
} catch (unlinkErr) {
|
||||
// Ignore unlink errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hero image exists and is valid
|
||||
*/
|
||||
async function isHeroValid(heroPath) {
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), heroPath);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
if (stats.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to read metadata to ensure it's a valid image
|
||||
await sharp(fullPath).metadata();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a hero image exists for a photo, regenerate if needed
|
||||
*/
|
||||
async function ensureHeroImage(photo) {
|
||||
const { db } = require('../database/db');
|
||||
const { resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
let originalPath;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
originalPath = resolvePhotoFilePath(event, photo);
|
||||
logger.info(`Ensuring hero image for photo ${photo.id} from source: ${originalPath}`);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original path for hero image (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if hero image exists and is valid
|
||||
if (photo.hero_path) {
|
||||
const isValid = await isHeroValid(photo.hero_path);
|
||||
if (isValid) {
|
||||
return photo.hero_path;
|
||||
}
|
||||
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
// Generate new hero image
|
||||
const newHeroPath = await generateHeroImage(originalPath, { regenerate: true });
|
||||
|
||||
if (newHeroPath) {
|
||||
// Update database with new hero path
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({ hero_path: newHeroPath });
|
||||
|
||||
logger.info(`Regenerated hero image for photo ${photo.id}`);
|
||||
return newHeroPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract capture date from EXIF metadata
|
||||
* @param {string} imagePath - Path to the image file
|
||||
* @returns {Date|null} - The capture date or null if not available
|
||||
*/
|
||||
async function extractCaptureDate(imagePath) {
|
||||
try {
|
||||
// Parse EXIF data, looking for common date fields
|
||||
const exif = await exifr.parse(imagePath, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate']
|
||||
});
|
||||
|
||||
if (!exif) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate
|
||||
const captureDate = exif.DateTimeOriginal ||
|
||||
exif.CreateDate ||
|
||||
exif.DateTimeDigitized ||
|
||||
exif.ModifyDate;
|
||||
|
||||
if (captureDate) {
|
||||
// exifr returns Date objects directly when parsing dates
|
||||
if (captureDate instanceof Date) {
|
||||
// Validate the date is reasonable (not in the future, not before 1990)
|
||||
const now = new Date();
|
||||
const minDate = new Date('1990-01-01');
|
||||
if (captureDate > minDate && captureDate <= now) {
|
||||
return captureDate;
|
||||
}
|
||||
}
|
||||
// Handle string dates if necessary
|
||||
if (typeof captureDate === 'string') {
|
||||
const parsed = new Date(captureDate);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Log only as debug - many images don't have EXIF data
|
||||
logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateThumbnail,
|
||||
isThumbnailValid,
|
||||
ensureThumbnail,
|
||||
generateVideoPlaceholder,
|
||||
generateHeroImage,
|
||||
isHeroValid,
|
||||
ensureHeroImage,
|
||||
extractCaptureDate
|
||||
};
|
||||
|
||||
@@ -23,13 +23,13 @@ class PhotoExportService {
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -37,9 +37,9 @@ class PhotoExportService {
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.file_size',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.size_bytes',
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
)
|
||||
.orderBy('photos.filename', 'asc');
|
||||
|
||||
@@ -86,7 +86,7 @@ class PhotoExportService {
|
||||
const { filename_format = 'original', separator = 'newline' } = options;
|
||||
|
||||
const filenames = photos.map(photo =>
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
|
||||
);
|
||||
|
||||
let content;
|
||||
@@ -126,14 +126,14 @@ class PhotoExportService {
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
'file_size',
|
||||
'created_at'
|
||||
'size_bytes',
|
||||
'uploaded_at'
|
||||
];
|
||||
|
||||
const rows = photos.map(photo => [
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename,
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
|
||||
photo.original_filename || '',
|
||||
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
|
||||
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
|
||||
photo.feedback_count || 0,
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
@@ -141,8 +141,8 @@ class PhotoExportService {
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
photo.file_size || '',
|
||||
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
||||
photo.size_bytes || '',
|
||||
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
@@ -170,7 +170,7 @@ class PhotoExportService {
|
||||
|
||||
for (const photo of photos) {
|
||||
const baseFilename = filename_format === 'original'
|
||||
? photo.original_filename
|
||||
? (photo.original_filename || photo.filename)
|
||||
: photo.filename;
|
||||
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
||||
@@ -209,21 +209,21 @@ class PhotoExportService {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename,
|
||||
original_filename: photo.original_filename || null,
|
||||
category: photo.category_name || null,
|
||||
rating: {
|
||||
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
|
||||
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
|
||||
count: photo.feedback_count || 0
|
||||
},
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_count || 0,
|
||||
comments: photo.comment_count || 0,
|
||||
dimensions: {
|
||||
width: photo.width,
|
||||
height: photo.height
|
||||
width: photo.width || null,
|
||||
height: photo.height || null
|
||||
},
|
||||
file_size: photo.file_size,
|
||||
created_at: photo.created_at
|
||||
size_bytes: photo.size_bytes || null,
|
||||
uploaded_at: photo.uploaded_at || null
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
let imageMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
// Process image: generate thumbnail and extract dimensions
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Extract image dimensions using sharp
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(newPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
imageMetadata = {
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
@@ -186,6 +201,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const photoData = {
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
@@ -205,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
// Add image dimensions if available
|
||||
if (!isVideo && imageMetadata) {
|
||||
photoData.width = imageMetadata.width;
|
||||
photoData.height = imageMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
|
||||
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Get photos for an event with optional filtering
|
||||
|
||||
@@ -4,9 +4,7 @@ const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -418,12 +416,14 @@ class RestoreService {
|
||||
let availableBytes = 0;
|
||||
let diskCheckSucceeded = false;
|
||||
try {
|
||||
const { exec } = require('child_process');
|
||||
const execAsync = promisify(exec);
|
||||
// Use root path as fallback if storage path doesn't exist yet
|
||||
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
||||
const parsed = parseInt(stdout.trim());
|
||||
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
|
||||
// Parse df output: last line, 4th column is available KB
|
||||
const lines = stdout.trim().split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const columns = lastLine.trim().split(/\s+/);
|
||||
const parsed = parseInt(columns[3]);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||
diskCheckSucceeded = true;
|
||||
@@ -492,15 +492,12 @@ class RestoreService {
|
||||
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
|
||||
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
|
||||
} else {
|
||||
// PostgreSQL backup
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
const env = { ...process.env, PGPASSWORD: password };
|
||||
await execAsync(
|
||||
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
|
||||
{ env }
|
||||
);
|
||||
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
|
||||
}
|
||||
|
||||
// Compress database backup
|
||||
@@ -513,8 +510,8 @@ class RestoreService {
|
||||
this.log('info', 'Backing up current files...');
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
|
||||
|
||||
await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`);
|
||||
|
||||
await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]);
|
||||
}
|
||||
|
||||
// Create backup manifest
|
||||
@@ -696,10 +693,10 @@ class RestoreService {
|
||||
|
||||
try {
|
||||
// Restore from backup
|
||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
|
||||
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
||||
|
||||
// Verify integrity
|
||||
const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`);
|
||||
const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']);
|
||||
if (!integrityCheck.stdout.includes('ok')) {
|
||||
throw new Error('Database integrity check failed after restore');
|
||||
}
|
||||
@@ -722,21 +719,12 @@ class RestoreService {
|
||||
// Drop and recreate database (extremely dangerous!)
|
||||
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
||||
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
|
||||
{ env }
|
||||
);
|
||||
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
|
||||
{ env }
|
||||
);
|
||||
|
||||
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
|
||||
|
||||
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
|
||||
|
||||
// Restore from backup
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
|
||||
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
|
||||
);
|
||||
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
|
||||
}
|
||||
|
||||
// Re-initialize database connection
|
||||
@@ -987,14 +975,11 @@ class RestoreService {
|
||||
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
||||
} else {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
const env = { ...process.env, PGPASSWORD: password };
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
|
||||
{ env }
|
||||
);
|
||||
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
|
||||
}
|
||||
|
||||
await fs.unlink(decompressedPath);
|
||||
@@ -1004,7 +989,7 @@ class RestoreService {
|
||||
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
||||
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`);
|
||||
await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]);
|
||||
}
|
||||
|
||||
this.log('info', 'Rollback completed successfully');
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
let cachedRobotsTxt = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
||||
|
||||
function parseSetting(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeoSettings() {
|
||||
const rows = await db('app_settings')
|
||||
.where('setting_type', 'seo')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settings = {};
|
||||
for (const row of rows) {
|
||||
settings[row.setting_key] = parseSetting(row.setting_value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
const SOCIAL_BOTS = [
|
||||
'Twitterbot',
|
||||
'facebookexternalhit',
|
||||
'LinkedInBot',
|
||||
'Slackbot',
|
||||
'WhatsApp',
|
||||
'TelegramBot',
|
||||
'Discordbot'
|
||||
];
|
||||
|
||||
async function generateRobotsTxt() {
|
||||
const now = Date.now();
|
||||
if (cachedRobotsTxt && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
||||
return cachedRobotsTxt;
|
||||
}
|
||||
|
||||
const settings = await getSeoSettings();
|
||||
const allowIndexing = settings.seo_allow_indexing === true;
|
||||
const blockAiCrawlers = settings.seo_block_ai_crawlers !== false;
|
||||
const blockSocialBots = settings.seo_block_social_bots === true;
|
||||
const aiAgents = Array.isArray(settings.seo_blocked_ai_agents)
|
||||
? settings.seo_blocked_ai_agents
|
||||
: [];
|
||||
const customRules = Array.isArray(settings.seo_custom_rules)
|
||||
? settings.seo_custom_rules
|
||||
: [];
|
||||
const sitemapUrl = settings.seo_sitemap_url || '';
|
||||
|
||||
const lines = [];
|
||||
|
||||
// Always block admin and API paths for all agents
|
||||
lines.push('# Protected paths');
|
||||
lines.push('User-agent: *');
|
||||
lines.push('Disallow: /admin');
|
||||
lines.push('Disallow: /api');
|
||||
lines.push('');
|
||||
|
||||
if (!allowIndexing) {
|
||||
// Block everything for all agents
|
||||
lines.push('# Indexing disabled - block all crawlers');
|
||||
lines.push('User-agent: *');
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Block AI crawlers if enabled
|
||||
if (blockAiCrawlers && aiAgents.length > 0) {
|
||||
lines.push('# AI/LLM crawler blocking');
|
||||
for (const agent of aiAgents) {
|
||||
lines.push(`User-agent: ${agent}`);
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Block social bots if enabled
|
||||
if (blockSocialBots) {
|
||||
lines.push('# Social media bot blocking');
|
||||
for (const bot of SOCIAL_BOTS) {
|
||||
lines.push(`User-agent: ${bot}`);
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Custom rules
|
||||
if (customRules.length > 0) {
|
||||
lines.push('# Custom rules');
|
||||
for (const rule of customRules) {
|
||||
if (rule.userAgent && Array.isArray(rule.disallow)) {
|
||||
lines.push(`User-agent: ${rule.userAgent}`);
|
||||
for (const path of rule.disallow) {
|
||||
lines.push(`Disallow: ${path}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sitemap
|
||||
if (sitemapUrl) {
|
||||
lines.push(`Sitemap: ${sitemapUrl}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const result = lines.join('\n');
|
||||
cachedRobotsTxt = result;
|
||||
cacheTimestamp = now;
|
||||
return result;
|
||||
}
|
||||
|
||||
function clearRobotsTxtCache() {
|
||||
cachedRobotsTxt = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRobotsTxt,
|
||||
clearRobotsTxtCache
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Update Notification Service
|
||||
* Checks for updates and sends email notifications to administrators.
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { checkForUpdates } = require('./updateCheckService');
|
||||
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Get update notification settings from database
|
||||
*/
|
||||
async function getUpdateNotificationSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'update_email_notifications_enabled',
|
||||
'update_email_recipients',
|
||||
'last_notified_version'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const result = {};
|
||||
for (const setting of settings) {
|
||||
try {
|
||||
result[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
result[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: result.update_email_notifications_enabled === true,
|
||||
recipients: result.update_email_recipients || '',
|
||||
lastNotifiedVersion: result.last_notified_version || ''
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching update notification settings:', error);
|
||||
return {
|
||||
enabled: false,
|
||||
recipients: '',
|
||||
lastNotifiedVersion: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last notified version in database
|
||||
*/
|
||||
async function updateLastNotifiedVersion(version) {
|
||||
try {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'last_notified_version')
|
||||
.update({
|
||||
setting_value: JSON.stringify(version),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating last notified version:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin email addresses to notify
|
||||
* If recipients setting is empty, get all active admin emails
|
||||
*/
|
||||
async function getNotificationRecipients(recipientsSetting) {
|
||||
try {
|
||||
if (recipientsSetting && recipientsSetting.trim()) {
|
||||
// Use configured recipients (comma-separated)
|
||||
return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Fallback: get all active admin user emails
|
||||
const admins = await db('admin_users')
|
||||
.where('is_active', true)
|
||||
.whereNotNull('email')
|
||||
.select('email');
|
||||
|
||||
return admins.map(admin => admin.email).filter(Boolean);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching notification recipients:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and send notification emails if new version is available
|
||||
*/
|
||||
async function checkAndNotifyUpdates() {
|
||||
logger.info('Update notification service: Checking for updates...');
|
||||
|
||||
try {
|
||||
// Check if update notifications are enabled
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
|
||||
if (!settings.enabled) {
|
||||
logger.info('Update email notifications are disabled');
|
||||
return { notified: false, reason: 'notifications_disabled' };
|
||||
}
|
||||
|
||||
// Check for available updates
|
||||
const updateInfo = await checkForUpdates();
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
logger.info('No updates available');
|
||||
return { notified: false, reason: 'no_updates' };
|
||||
}
|
||||
|
||||
const newVersion = updateInfo.latest.forChannel;
|
||||
|
||||
// Check if we've already notified about this version
|
||||
if (settings.lastNotifiedVersion === newVersion) {
|
||||
logger.info(`Already notified about version ${newVersion}`);
|
||||
return { notified: false, reason: 'already_notified' };
|
||||
}
|
||||
|
||||
// Get recipients
|
||||
const recipients = await getNotificationRecipients(settings.recipients);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
logger.warn('No recipients configured for update notifications');
|
||||
return { notified: false, reason: 'no_recipients' };
|
||||
}
|
||||
|
||||
// Ensure email transporter is initialized
|
||||
await initializeTransporter();
|
||||
|
||||
// Send email to each recipient
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
|
||||
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const email of recipients) {
|
||||
try {
|
||||
await sendTemplateEmail(email, 'version_update_available', {
|
||||
current_version: updateInfo.current,
|
||||
new_version: newVersion,
|
||||
channel: channelLabel,
|
||||
release_notes_url: releaseNotesUrl,
|
||||
admin_url: `${frontendUrl}/admin`
|
||||
});
|
||||
successCount++;
|
||||
logger.info(`Update notification sent to ${email}`);
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`Failed to send update notification to ${email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last notified version
|
||||
if (successCount > 0) {
|
||||
await updateLastNotifiedVersion(newVersion);
|
||||
logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`);
|
||||
}
|
||||
|
||||
return {
|
||||
notified: successCount > 0,
|
||||
newVersion,
|
||||
successCount,
|
||||
errorCount,
|
||||
totalRecipients: recipients.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error in update notification service:', error);
|
||||
return { notified: false, reason: 'error', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force send update notification (for manual trigger from admin UI)
|
||||
*/
|
||||
async function sendUpdateNotificationNow() {
|
||||
logger.info('Manually triggering update notification...');
|
||||
|
||||
try {
|
||||
// Check for available updates
|
||||
const updateInfo = await checkForUpdates(true); // Force refresh
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
return { success: false, message: 'No updates available' };
|
||||
}
|
||||
|
||||
const newVersion = updateInfo.latest.forChannel;
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
|
||||
// Get recipients
|
||||
const recipients = await getNotificationRecipients(settings.recipients);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return { success: false, message: 'No recipients configured' };
|
||||
}
|
||||
|
||||
// Ensure email transporter is initialized
|
||||
await initializeTransporter();
|
||||
|
||||
// Send email to each recipient
|
||||
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
|
||||
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const email of recipients) {
|
||||
try {
|
||||
await sendTemplateEmail(email, 'version_update_available', {
|
||||
current_version: updateInfo.current,
|
||||
new_version: newVersion,
|
||||
channel: channelLabel,
|
||||
release_notes_url: releaseNotesUrl
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`Failed to send update notification to ${email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last notified version
|
||||
if (successCount > 0) {
|
||||
await updateLastNotifiedVersion(newVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount > 0,
|
||||
newVersion,
|
||||
successCount,
|
||||
errorCount,
|
||||
totalRecipients: recipients.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error sending manual update notification:', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAndNotifyUpdates,
|
||||
sendUpdateNotificationNow,
|
||||
getUpdateNotificationSettings,
|
||||
getNotificationRecipients
|
||||
};
|
||||
@@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000;
|
||||
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
// Map of file extension to MIME type(s)
|
||||
const EXTENSION_TO_MIME = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'mp4': 'video/mp4',
|
||||
'm4v': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
||||
|
||||
let cachedAllowedTypes = null;
|
||||
let allowedTypesCacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (setting) => {
|
||||
if (!setting || setting.setting_value == null) {
|
||||
return null;
|
||||
@@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => {
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a comma-separated list of file extensions into an array of MIME types.
|
||||
* Unknown extensions are silently ignored.
|
||||
*/
|
||||
const extensionsToMimeTypes = (extString) => {
|
||||
if (!extString || typeof extString !== 'string') {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
const mimeSet = new Set();
|
||||
extString.split(',').forEach(ext => {
|
||||
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
|
||||
const mime = EXTENSION_TO_MIME[cleaned];
|
||||
if (mime) {
|
||||
mimeSet.add(mime);
|
||||
}
|
||||
});
|
||||
|
||||
if (mimeSet.size === 0) {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
return Array.from(mimeSet);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the allowed MIME types for uploads from the database setting.
|
||||
* Returns an array of MIME type strings, e.g. ['image/jpeg', 'image/png', 'video/mp4'].
|
||||
*/
|
||||
const getAllowedMimeTypes = async () => {
|
||||
if (Date.now() < allowedTypesCacheExpiresAt && cachedAllowedTypes) {
|
||||
return cachedAllowedTypes;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where({ setting_key: 'general_allowed_file_types' })
|
||||
.first();
|
||||
|
||||
let rawValue = setting?.setting_value;
|
||||
if (typeof rawValue === 'string') {
|
||||
try { rawValue = JSON.parse(rawValue); } catch { /* keep string */ }
|
||||
}
|
||||
|
||||
const mimeTypes = extensionsToMimeTypes(rawValue);
|
||||
cachedAllowedTypes = mimeTypes;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return mimeTypes;
|
||||
} catch (error) {
|
||||
console.error('Failed to read allowed file types setting:', error.message);
|
||||
const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
cachedAllowedTypes = fallback;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllowedTypesCache = () => {
|
||||
allowedTypesCacheExpiresAt = 0;
|
||||
cachedAllowedTypes = null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMaxFilesPerUpload,
|
||||
clearMaxFilesPerUploadCache,
|
||||
getAllowedMimeTypes,
|
||||
clearAllowedTypesCache,
|
||||
extensionsToMimeTypes,
|
||||
EXTENSION_TO_MIME,
|
||||
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD,
|
||||
DEFAULT_ALLOWED_FILE_TYPES
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
// Queue invitation email
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||
await queueEmail(null, email, 'admin_invitation', {
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
invite_link: `${frontendUrl}/invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* WatermarkGeneratorService
|
||||
*
|
||||
* Handles batch generation of pre-watermarked images for fast serving.
|
||||
* This service is responsible for:
|
||||
* - Generating watermarks for newly uploaded photos
|
||||
* - Regenerating all watermarks when settings change
|
||||
* - Tracking regeneration progress
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkGeneratorService {
|
||||
constructor() {
|
||||
// Track active regeneration jobs
|
||||
this.activeJobs = new Map();
|
||||
// Batch size for processing (to manage memory)
|
||||
this.batchSize = 10;
|
||||
// Concurrent processing limit
|
||||
this.concurrentLimit = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermark for a single photo
|
||||
* @param {number} photoId - The photo ID
|
||||
* @returns {Object} Result with success status and watermark path
|
||||
*/
|
||||
async generateForPhoto(photoId) {
|
||||
try {
|
||||
// Get photo with event info
|
||||
const photo = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.id', photoId)
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return { success: false, error: 'Photo not found' };
|
||||
}
|
||||
|
||||
// Skip video files
|
||||
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
|
||||
return { success: false, error: 'Videos do not support watermarks' };
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Resolve the original file path
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, error: 'Could not resolve photo path' };
|
||||
}
|
||||
|
||||
// Generate and save watermark
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
// Update database with watermark path
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full file path for a photo
|
||||
*/
|
||||
resolvePhotoPath(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
// Handle external/reference mode
|
||||
if (photo.source_mode === 'reference' && photo.external_relpath) {
|
||||
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
|
||||
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
|
||||
}
|
||||
|
||||
// Standard managed mode
|
||||
if (photo.file_path) {
|
||||
// file_path might be absolute or relative
|
||||
if (path.isAbsolute(photo.file_path)) {
|
||||
return photo.file_path;
|
||||
}
|
||||
return path.join(storagePath, photo.file_path);
|
||||
}
|
||||
|
||||
// Fallback to constructing path from slug and filename
|
||||
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarks for all photos in an event
|
||||
* @param {number} eventId - The event ID
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async generateForEvent(eventId, onProgress = null) {
|
||||
const results = { total: 0, success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
// Get all photos for the event (excluding videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get watermark settings once
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { ...results, errors: ['Watermarking is disabled'] };
|
||||
}
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermarks for event ${eventId}:`, error);
|
||||
return { ...results, errors: [...results.errors, error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process watermark for a single photo (internal helper)
|
||||
*/
|
||||
async processPhotoWatermark(photo, settings) {
|
||||
try {
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
|
||||
}
|
||||
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return { ...result, photoId: photo.id };
|
||||
} catch (error) {
|
||||
return { success: false, photoId: photo.id, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate watermarks for all photos in the system
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async regenerateAll(onProgress = null) {
|
||||
const jobId = Date.now().toString();
|
||||
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
|
||||
|
||||
try {
|
||||
this.activeJobs.set(jobId, results);
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
results.status = 'completed';
|
||||
results.errors.push('Watermarking is disabled');
|
||||
return results;
|
||||
}
|
||||
|
||||
// First, clear existing watermarks from DB (the files will be overwritten)
|
||||
// This ensures stale paths don't persist if regeneration fails
|
||||
|
||||
// Get all image photos (exclude videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
results.status = 'completed';
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log(`Starting watermark regeneration for ${photos.length} photos`);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
// Check if job was cancelled
|
||||
if (!this.activeJobs.has(jobId)) {
|
||||
results.status = 'cancelled';
|
||||
return results;
|
||||
}
|
||||
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error && results.errors.length < 50) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update job status
|
||||
this.activeJobs.set(jobId, { ...results });
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
jobId,
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed,
|
||||
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between batches to prevent CPU saturation
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
results.status = 'completed';
|
||||
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error during watermark regeneration:', error);
|
||||
results.status = 'failed';
|
||||
results.errors.push(error.message);
|
||||
return results;
|
||||
} finally {
|
||||
// Clean up job tracking after a delay
|
||||
setTimeout(() => {
|
||||
this.activeJobs.delete(jobId);
|
||||
}, 60000); // Keep for 1 minute for status queries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all watermarks (when watermarking is disabled)
|
||||
*/
|
||||
async clearAllWatermarks() {
|
||||
try {
|
||||
// Get all photos with watermarks
|
||||
const photos = await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.select('id', 'watermark_path');
|
||||
|
||||
// Delete watermark files
|
||||
for (const photo of photos) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
}
|
||||
|
||||
// Clear database paths
|
||||
await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
|
||||
console.log(`Cleared ${photos.length} watermarks`);
|
||||
return { success: true, cleared: photos.length };
|
||||
} catch (error) {
|
||||
console.error('Error clearing watermarks:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete watermark for a specific photo
|
||||
*/
|
||||
async deleteForPhoto(photoId) {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.select('watermark_path')
|
||||
.first();
|
||||
|
||||
if (photo && photo.watermark_path) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Error deleting watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of an active regeneration job
|
||||
*/
|
||||
getJobStatus(jobId) {
|
||||
return this.activeJobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active regeneration job
|
||||
*/
|
||||
cancelJob(jobId) {
|
||||
if (this.activeJobs.has(jobId)) {
|
||||
this.activeJobs.delete(jobId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an active regeneration job
|
||||
*/
|
||||
hasActiveJob() {
|
||||
for (const [, job] of this.activeJobs) {
|
||||
if (job.status === 'running') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of photos needing watermark generation
|
||||
*/
|
||||
async getPendingCount() {
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await db('photos')
|
||||
.whereNull('watermark_path')
|
||||
.whereNot(function() {
|
||||
this.where('media_type', 'video')
|
||||
.orWhere('mime_type', 'like', 'video/%');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
return parseInt(result.count) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkGeneratorService();
|
||||
@@ -2,6 +2,7 @@ const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkService {
|
||||
constructor() {
|
||||
@@ -232,6 +233,125 @@ class WatermarkService {
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watermarks directory path, creating it if needed
|
||||
*/
|
||||
async getWatermarksDir() {
|
||||
const watermarksDir = path.join(getStoragePath(), 'watermarks');
|
||||
try {
|
||||
await fs.access(watermarksDir);
|
||||
} catch {
|
||||
await fs.mkdir(watermarksDir, { recursive: true });
|
||||
}
|
||||
return watermarksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file extension from a filename
|
||||
*/
|
||||
getFileExtension(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
// Map common extensions
|
||||
if (ext === '.jpeg') return '.jpg';
|
||||
return ext || '.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarked version of a photo and save to disk
|
||||
* @param {Object} photo - Photo object with id, filename, and path info
|
||||
* @param {string} originalPath - Full path to the original image file
|
||||
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
|
||||
* @returns {Object} { success, watermarkPath, error }
|
||||
*/
|
||||
async generateAndSaveWatermark(photo, originalPath, settings = null) {
|
||||
try {
|
||||
// Get settings if not provided
|
||||
if (!settings) {
|
||||
settings = await this.getWatermarkSettings();
|
||||
}
|
||||
|
||||
// If watermarking is disabled, return early
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Verify original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch {
|
||||
return { success: false, watermarkPath: null, error: 'Original file not found' };
|
||||
}
|
||||
|
||||
// Generate watermarked buffer using existing method
|
||||
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
|
||||
|
||||
// Determine output path
|
||||
const watermarksDir = await this.getWatermarksDir();
|
||||
const ext = this.getFileExtension(photo.filename);
|
||||
const outputFilename = `${photo.id}_watermarked${ext}`;
|
||||
const outputPath = path.join(watermarksDir, outputFilename);
|
||||
|
||||
// Write the watermarked image to disk
|
||||
await fs.writeFile(outputPath, watermarkedBuffer);
|
||||
|
||||
// Return relative path for database storage
|
||||
const relativePath = `watermarks/${outputFilename}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
watermarkPath: relativePath,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photo.id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
watermarkPath: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pre-generated watermark file
|
||||
* @param {string} watermarkPath - Relative path to the watermark file
|
||||
* @returns {boolean} - True if deleted successfully
|
||||
*/
|
||||
async deleteWatermarkFile(watermarkPath) {
|
||||
if (!watermarkPath) return false;
|
||||
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), watermarkPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// File might not exist, which is fine
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error deleting watermark file:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash of current watermark settings for change detection
|
||||
* @returns {string} - Hash string of settings
|
||||
*/
|
||||
async getSettingsHash() {
|
||||
const settings = await this.getWatermarkSettings();
|
||||
if (!settings) return '';
|
||||
|
||||
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
|
||||
// Simple hash for change detection (not cryptographic)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hashData.length; i++) {
|
||||
const char = hashData.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkService();
|
||||
@@ -76,7 +76,7 @@ class XmpGenerator {
|
||||
* @returns {string} Description XML
|
||||
*/
|
||||
generateDescription(photo) {
|
||||
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
|
||||
const rating = photo.average_rating ? parseFloat(photo.average_rating).toFixed(1) : '0';
|
||||
const likes = photo.like_count || 0;
|
||||
const favorites = photo.favorite_count || 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/**
|
||||
* Safe command execution utilities using spawn (shell: false).
|
||||
* These prevent command injection by never invoking a shell interpreter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run a command with arguments, returning { stdout, stderr }.
|
||||
* Equivalent to execAsync(cmd) but safe from injection.
|
||||
*/
|
||||
function spawnAsync(cmd, args = [], options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
shell: false,
|
||||
...options,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
|
||||
child.stdout.on('data', chunk => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', chunk => stderrChunks.push(chunk));
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString();
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
if (code !== 0) {
|
||||
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
|
||||
err.code = code;
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and redirect stdout to a file (replaces shell `> file`).
|
||||
*/
|
||||
function spawnToFile(cmd, args, outputPath, options = {}) {
|
||||
const fs = require('fs');
|
||||
return new Promise((resolve, reject) => {
|
||||
const outStream = fs.createWriteStream(outputPath);
|
||||
const child = spawn(cmd, args, {
|
||||
shell: false,
|
||||
...options,
|
||||
stdio: ['ignore', outStream, 'pipe']
|
||||
});
|
||||
|
||||
const stderrChunks = [];
|
||||
child.stderr.on('data', chunk => stderrChunks.push(chunk));
|
||||
|
||||
child.on('error', (err) => {
|
||||
outStream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
outStream.end();
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
if (code !== 0) {
|
||||
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
|
||||
err.code = code;
|
||||
err.stderr = stderr;
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and pipe a file into stdin (replaces shell `< file`).
|
||||
*/
|
||||
function spawnFromFile(cmd, args, inputPath, options = {}) {
|
||||
const fs = require('fs');
|
||||
return new Promise((resolve, reject) => {
|
||||
const inStream = fs.createReadStream(inputPath);
|
||||
const child = spawn(cmd, args, {
|
||||
shell: false,
|
||||
...options,
|
||||
stdio: [inStream, 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
child.stdout.on('data', chunk => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', chunk => stderrChunks.push(chunk));
|
||||
|
||||
child.on('error', (err) => {
|
||||
inStream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString();
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
if (code !== 0) {
|
||||
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
|
||||
err.code = code;
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { spawnAsync, spawnToFile, spawnFromFile };
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- REDIS_HOST=redis
|
||||
- STORAGE_PATH=/app/storage
|
||||
- PHOTOS_DIR=/app/storage/events
|
||||
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
|
||||
volumes:
|
||||
|
||||
+2
-2
@@ -70,7 +70,7 @@ services:
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
- "127.0.0.1:${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
@@ -89,7 +89,7 @@ services:
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
|
||||
+3
-8
@@ -12,9 +12,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -30,12 +27,10 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage (use Alpine with patched libpng/c-ares)
|
||||
FROM nginx:1.27-alpine
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities
|
||||
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
|
||||
# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies
|
||||
|
||||
@@ -22,10 +22,10 @@ ENV VITE_API_URL=$VITE_API_URL
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies
|
||||
|
||||
+19
-7
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -8,8 +9,8 @@ server {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# Allow larger file uploads (up to 100MB)
|
||||
client_max_body_size 100M;
|
||||
# Allow larger file uploads (up to 1GB for video support)
|
||||
client_max_body_size 1G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# Gzip compression
|
||||
@@ -21,9 +22,9 @@ server {
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" 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;
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
@@ -60,8 +61,8 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
# Allow larger uploads for API endpoints
|
||||
client_max_body_size 100M;
|
||||
# Allow larger uploads for API endpoints (up to 1GB for video support)
|
||||
client_max_body_size 1G;
|
||||
client_body_timeout 300s;
|
||||
}
|
||||
|
||||
@@ -111,6 +112,17 @@ server {
|
||||
proxy_cache_valid 404 1m;
|
||||
}
|
||||
|
||||
# Dynamic robots.txt served by backend
|
||||
location = /robots.txt {
|
||||
set $backend_upstream backend;
|
||||
proxy_pass http://$backend_upstream:3000/robots.txt;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Delegate root requests to backend for public landing page handling
|
||||
location = / {
|
||||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||||
|
||||
Generated
+308
-128
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.15",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.15",
|
||||
"version": "2.5.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -24,13 +24,16 @@
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"framer-motion": "^12.33.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"justified-layout": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"photoswipe": "^5.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "^2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -38,9 +41,12 @@
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-image-gallery": "^1.2.11",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"yet-another-react-lightbox": "^3.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
@@ -1281,9 +1287,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1297,9 +1303,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1311,9 +1317,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1325,9 +1331,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1339,9 +1345,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1353,9 +1359,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1367,9 +1373,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1381,9 +1387,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1395,9 +1401,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1409,9 +1415,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1423,9 +1429,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1437,9 +1443,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1451,9 +1471,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1465,9 +1499,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1479,9 +1513,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1493,9 +1527,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1507,9 +1541,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1520,9 +1554,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1533,10 +1567,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1548,9 +1596,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1562,9 +1610,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1576,9 +1624,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1590,9 +1638,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2343,7 +2391,7 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
@@ -2578,13 +2626,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2819,9 +2867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2972,13 +3020,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -4089,6 +4137,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.33.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz",
|
||||
"integrity": "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.33.0",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -4664,6 +4739,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/justified-layout": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz",
|
||||
"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -4740,9 +4821,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -4827,9 +4908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
@@ -4927,9 +5008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4939,6 +5020,21 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.33.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.33.0.tgz",
|
||||
"integrity": "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5186,6 +5282,15 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/photoswipe": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.4.4.tgz",
|
||||
"integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5813,6 +5918,27 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-photo-album": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
|
||||
"integrity": "sha512-pPaCoxEfVDhowpqxECq4SOD5kzP1Uao8PEN11Jasxayv4cZjad3Fy8SlKt6wvtLnVJRtOjsQDU/ZnnUuberwMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/igordanchenko"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18 || ^19",
|
||||
"react": "^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5824,12 +5950,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
|
||||
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -5839,13 +5965,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
|
||||
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1",
|
||||
"react-router": "6.30.2"
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -5961,9 +6087,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5977,28 +6103,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -6227,6 +6356,25 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swiper": {
|
||||
"version": "12.1.2",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz",
|
||||
"integrity": "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/swiperjs"
|
||||
},
|
||||
{
|
||||
"type": "open_collective",
|
||||
"url": "http://opencollective.com/swiper"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -6447,6 +6595,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -6890,6 +7044,32 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yet-another-react-lightbox": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.28.0.tgz",
|
||||
"integrity": "sha512-bHjG1/DS4aWPkKb/6hNp0+zcgjda27wQlXBpJvb9TcblC1U1dY3Ih0pe85O8/ZLz+OIy+ZvLxN2gmsbDW0Q/QQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/igordanchenko"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16 || ^17 || ^18 || ^19",
|
||||
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.3.3",
|
||||
"version": "2.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -28,13 +28,16 @@
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"framer-motion": "^12.33.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"justified-layout": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"photoswipe": "^5.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "^2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -42,9 +45,12 @@
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-image-gallery": "^1.2.11",
|
||||
"react-intersection-observer": "^9.4.3",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"yet-another-react-lightbox": "^3.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
|
||||
+18
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
SettingsPage,
|
||||
BackupManagement,
|
||||
CMSPage,
|
||||
UserManagementPage
|
||||
UserManagementPage,
|
||||
EventTypesPage
|
||||
} from './pages/admin';
|
||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { getApiBaseUrl } from './utils/url';
|
||||
@@ -44,6 +45,17 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function App() {
|
||||
// Track dark mode for toast theming
|
||||
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setToastTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Initialize Umami Analytics based on settings
|
||||
useEffect(() => {
|
||||
const initializeAnalytics = async () => {
|
||||
@@ -102,6 +114,7 @@ function App() {
|
||||
<ThemeProvider>
|
||||
<GlobalThemeProvider>
|
||||
<DynamicFavicon />
|
||||
<RobotsMetaTags />
|
||||
<Router>
|
||||
<MaintenanceWrapper>
|
||||
<SkipLink />
|
||||
@@ -128,6 +141,7 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="event-types" element={<EventTypesPage />} />
|
||||
<Route path="backup" element={<BackupManagement />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
@@ -163,7 +177,7 @@ function App() {
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
theme={toastTheme}
|
||||
/>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
|
||||
import { AdminDarkModeProvider } from '../../contexts/AdminDarkModeContext';
|
||||
|
||||
export const AdminAuthWrapper: React.FC = () => {
|
||||
return (
|
||||
<AdminAuthProvider>
|
||||
<PermissionsProvider>
|
||||
<Outlet />
|
||||
<AdminDarkModeProvider>
|
||||
<Outlet />
|
||||
</AdminDarkModeProvider>
|
||||
</PermissionsProvider>
|
||||
</AdminAuthProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||
import { LanguageSelector } from '../common';
|
||||
@@ -20,6 +21,7 @@ interface AdminHeaderProps {
|
||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAdminAuth();
|
||||
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||
@@ -68,7 +70,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const unreadCount = notificationsData?.unreadCount || 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Left side - Menu button, Logo, and Date */}
|
||||
@@ -87,8 +89,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
</div>
|
||||
|
||||
{/* Date display - hidden on smaller screens */}
|
||||
<div className="hidden xl:block pl-3 border-l border-neutral-200 ml-1">
|
||||
<p className="text-base text-neutral-700">
|
||||
<div className="hidden xl:block pl-3 border-l border-neutral-200 dark:border-neutral-700 ml-1">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-300">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,12 +100,21 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
@@ -113,14 +124,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
|
||||
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 flex items-center gap-1"
|
||||
title={t('admin.markAllRead')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
@@ -129,7 +140,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearAllMutation.mutate()}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
|
||||
title={t('admin.clearAll')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -139,7 +150,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.noNotificationsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -148,7 +159,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
|
||||
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
|
||||
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
|
||||
}`}
|
||||
>
|
||||
@@ -157,10 +168,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<Bell className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-neutral-900">
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{notificationsService.formatNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -171,10 +182,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
)}
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-neutral-100 text-center">
|
||||
<button
|
||||
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
|
||||
<button
|
||||
onClick={() => setShowNotifications(false)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
>
|
||||
{t('admin.close')}
|
||||
</button>
|
||||
@@ -188,11 +199,11 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
@@ -201,17 +212,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
|
||||
{/* User dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-neutral-200 py-1">
|
||||
<div className="px-4 py-2 border-b border-neutral-100 sm:hidden">
|
||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
|
||||
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
navigate('/admin/settings');
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('navigation.settings')}
|
||||
@@ -221,14 +232,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
setShowUserMenu(false);
|
||||
setShowPasswordModal(true);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
{t('admin.changePassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('common.logout')}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const AdminLayout: React.FC = () => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-neutral-600">Loading...</p>
|
||||
@@ -31,7 +31,7 @@ export const AdminLayout: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
|
||||
{/* Mandatory Password Change Modal */}
|
||||
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -7,6 +7,12 @@ import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AdminPhotoGridProps {
|
||||
photos: AdminPhoto[];
|
||||
@@ -14,6 +20,7 @@ interface AdminPhotoGridProps {
|
||||
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
||||
onPhotosDeleted: () => void;
|
||||
onSelectionChange?: (selectedIds: number[]) => void;
|
||||
categories?: CategoryOption[];
|
||||
}
|
||||
|
||||
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
@@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
eventId,
|
||||
onPhotoClick,
|
||||
onPhotosDeleted,
|
||||
onSelectionChange
|
||||
onSelectionChange,
|
||||
categories = []
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
|
||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
|
||||
|
||||
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
@@ -125,6 +135,35 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToCategory = async (categoryId: number | null) => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
setIsUpdatingCategory(true);
|
||||
const selectedIds = Array.from(selectedPhotos);
|
||||
|
||||
try {
|
||||
await photosService.updatePhotosCategory(eventId, selectedIds, categoryId);
|
||||
const categoryName = categoryId
|
||||
? categories.find(c => Number(c.id) === categoryId)?.name || t('photos.selectedCategory', 'selected category')
|
||||
: t('photos.uncategorized', 'Uncategorized');
|
||||
toast.success(
|
||||
t('photos.movedToCategory', '{{count}} photos moved to {{category}}', {
|
||||
count: selectedIds.length,
|
||||
category: categoryName
|
||||
})
|
||||
);
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onSelectionChange?.([]);
|
||||
setIsCategoryModalOpen(false);
|
||||
onPhotosDeleted(); // Refresh the photo list
|
||||
} catch {
|
||||
toast.error(t('photos.moveToCategoryFailed', 'Failed to move photos to category'));
|
||||
} finally {
|
||||
setIsUpdatingCategory(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Action Bar */}
|
||||
@@ -151,9 +190,17 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{selectedPhotos.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsCategoryModalOpen(true)}
|
||||
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
@@ -168,7 +215,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-600">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('gallery.photosCount', { count: photos.length })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +234,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<div
|
||||
key={photo.id}
|
||||
data-testid={`admin-photo-tile-${photo.id}`}
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 transition-opacity ${
|
||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||
onClick={() => !isDeleting && onPhotoClick(photo, index)}
|
||||
@@ -240,6 +287,11 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<p className="text-white text-xs font-medium truncate mb-1">
|
||||
{photo.filename}
|
||||
</p>
|
||||
{photo.original_filename && photo.original_filename !== photo.filename && (
|
||||
<p className="text-white/60 text-[10px] truncate mb-1">
|
||||
Original: {photo.original_filename}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-white/80 text-xs mb-2">
|
||||
{photosService.formatBytes(photo.size)}
|
||||
</p>
|
||||
@@ -306,9 +358,19 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{photos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Category Modal */}
|
||||
<BulkCategoryModal
|
||||
isOpen={isCategoryModalOpen}
|
||||
onClose={() => setIsCategoryModalOpen(false)}
|
||||
onConfirm={handleMoveToCategory}
|
||||
photoCount={selectedPhotos.size}
|
||||
categories={categories}
|
||||
isLoading={isUpdatingCategory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -230,7 +230,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
|
||||
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
|
||||
<h3 className="text-white font-medium text-lg">{currentPhoto.filename}</h3>
|
||||
{currentPhoto.original_filename && currentPhoto.original_filename !== currentPhoto.filename && (
|
||||
<p className="text-neutral-400 text-sm">Original: {currentPhoto.original_filename}</p>
|
||||
)}
|
||||
<div className="mb-4" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Palette,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Users
|
||||
Users,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -39,6 +40,7 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
||||
@@ -57,15 +59,15 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-neutral-200 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-700 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-screen lg:h-full">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -88,8 +90,8 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
onClick={() => onClose()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
|
||||
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`w-5 h-5 mr-3 ${
|
||||
@@ -136,26 +138,26 @@ const StorageInfo: React.FC = () => {
|
||||
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
||||
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
||||
const containerClass = isOverSoftLimit
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: 'bg-neutral-100';
|
||||
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800';
|
||||
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<div className="p-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
<div className="mt-2 w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div
|
||||
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,8 +152,8 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('backup.configuration.enableBackup')}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.configuration.enableBackup')}</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('backup.configuration.enableBackupHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -164,15 +164,15 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||
<div className="w-11 h-6 bg-neutral-200 dark:bg-neutral-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 dark:after:border-neutral-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Destination Configuration */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.destinationType')}</h3>
|
||||
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.destinationType')}</h3>
|
||||
|
||||
{/* Destination Type Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{destinationTypes.map((type) => {
|
||||
@@ -184,17 +184,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
onClick={() => handleChange('backup_destination_type', type.id)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.backup_destination_type === type.id
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-8 w-8 mb-2 mx-auto ${
|
||||
formData.backup_destination_type === type.id
|
||||
? 'text-primary'
|
||||
: 'text-gray-400'
|
||||
: 'text-neutral-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-gray-900">{type.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{type.description}</p>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{type.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -205,7 +205,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
{formData.backup_destination_type === 'local' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.destinationPath')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -215,7 +215,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.configuration.fields.destinationPathHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.rsyncHost')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -238,7 +238,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.rsyncUser')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -251,7 +251,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.rsyncPath')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -263,7 +263,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.rsyncSshKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -271,18 +271,18 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
value={formData.backup_rsync_ssh_key}
|
||||
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
|
||||
placeholder={t('backup.configuration.fields.rsyncSshKeyPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
|
||||
className="absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.configuration.fields.rsyncSshKeyHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -292,7 +292,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
{formData.backup_destination_type === 's3' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.s3Endpoint')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -302,13 +302,13 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
placeholder="https://s3.amazonaws.com"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.configuration.fields.s3EndpointHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.s3Bucket')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -320,7 +320,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.s3Region')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -333,7 +333,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.s3AccessKey')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -345,7 +345,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.fields.s3SecretKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -359,7 +359,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecrets(prev => ({ ...prev, s3_secret_key: !prev.s3_secret_key }))}
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showSecrets.s3_secret_key ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
@@ -398,17 +398,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Schedule Configuration */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.schedule.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.schedule.title')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.schedule.scheduleType')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.backup_schedule}
|
||||
onChange={(e) => handleChange('backup_schedule', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
||||
>
|
||||
{scheduleOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -420,7 +420,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{formData.backup_schedule === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.schedule.customCron')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -429,14 +429,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
|
||||
placeholder="0 3 * * *"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.configuration.schedule.customCronHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.schedule.retention')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -446,7 +446,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.configuration.schedule.retentionHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -455,7 +455,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Backup Content Selection */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
@@ -463,14 +463,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_include_database}
|
||||
onChange={(e) => handleChange('backup_include_database', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
<Database className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -479,14 +479,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_include_photos}
|
||||
onChange={(e) => handleChange('backup_include_photos', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
<Image className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.photosHelp')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.photosHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -495,14 +495,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_include_archives}
|
||||
onChange={(e) => handleChange('backup_include_archives', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileArchive className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
<FileArchive className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -511,14 +511,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_include_thumbnails}
|
||||
onChange={(e) => handleChange('backup_include_thumbnails', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
||||
<Image className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -526,7 +526,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
@@ -534,11 +534,11 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_compression}
|
||||
onChange={(e) => handleChange('backup_compression', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -548,17 +548,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
type="checkbox"
|
||||
checked={formData.backup_encryption}
|
||||
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.backup_encryption && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -572,7 +572,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
|
||||
@@ -25,14 +25,14 @@ const StatCard = ({ icon: Icon, label, value, color = 'blue', subtext }) => (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-neutral-900 dark:text-neutral-100">{value}</p>
|
||||
{subtext && (
|
||||
<p className="mt-1 text-sm text-gray-500">{subtext}</p>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtext}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 bg-${color}-100 rounded-lg`}>
|
||||
<Icon className={`h-6 w-6 text-${color}-600`} />
|
||||
<div className={`p-3 bg-${color}-100 dark:bg-${color}-900/40 rounded-lg`}>
|
||||
<Icon className={`h-6 w-6 text-${color}-600 dark:text-${color}-400`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -86,14 +86,14 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
<div className="space-y-6">
|
||||
{/* Configuration Alert */}
|
||||
{!isConfigured && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('backup.dashboard.notConfigured.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
{t('backup.dashboard.notConfigured.message')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,8 +104,8 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{/* Health Score Card */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('backup.dashboard.health.title')}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 text-${healthColors[health.status]}-700`}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.dashboard.health.title')}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 dark:bg-${healthColors[health.status]}-900/40 text-${healthColors[health.status]}-700 dark:text-${healthColors[health.status]}-300`}>
|
||||
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
className="text-neutral-200 dark:text-neutral-700"
|
||||
/>
|
||||
<circle
|
||||
cx="48"
|
||||
@@ -134,14 +134,14 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-gray-900">{health.score}%</span>
|
||||
<span className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{health.score}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700 font-medium">{health.message}</p>
|
||||
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
|
||||
{lastBackup && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
@@ -206,10 +206,10 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{/* Recent Activity */}
|
||||
{status?.recentBackups && status.recentBackups.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
||||
<div className="space-y-3">
|
||||
{status.recentBackups.slice(0, 5).map((backup) => (
|
||||
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-neutral-100 dark:border-neutral-700 last:border-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
{backup.status === 'completed' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
@@ -219,19 +219,19 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{format(new Date(backup.created_at), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{formatBytes(backup.statistics?.total_size || 0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{backup.statistics?.files_processed || 0} files
|
||||
</p>
|
||||
</div>
|
||||
@@ -244,36 +244,36 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{/* Storage Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">Database</span>
|
||||
<Database className="h-5 w-5 text-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Database</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
statistics.database_backed_up ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
|
||||
statistics.database_backed_up ? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300' : 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||
}`}>
|
||||
{statistics.database_backed_up ? t('backup.dashboard.coverage.included') : t('backup.dashboard.coverage.excluded')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
<Image className="h-5 w-5 text-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{statistics.photos_backed_up || 0} {t('common.of')} {statistics.total_photos || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileArchive className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
<FileArchive className="h-5 w-5 text-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{config?.backup_destination_type === 's3' ? (
|
||||
@@ -289,15 +289,15 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
) : config?.backup_destination_type === 'rsync' ? (
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
) : (
|
||||
<HardDrive className="h-5 w-5 text-gray-500" />
|
||||
<HardDrive className="h-5 w-5 text-neutral-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{config?.backup_destination_type
|
||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{config?.backup_destination_type
|
||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||
: t('backup.dashboard.notConfigured.title')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
|
||||
? `Bucket: ${config.backup_s3_bucket}`
|
||||
: config?.backup_destination_type === 'local' && config?.backup_destination_path
|
||||
@@ -308,12 +308,12 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{config?.backup_retention_days && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="mt-4 p-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
<Info className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t('backup.configuration.schedule.retentionDays')} {config.backup_retention_days} {t('backup.configuration.schedule.retentionHelp').replace('days (older backups will be automatically deleted)', '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ export const BackupHistory = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('backup.history.searchPlaceholder')}
|
||||
@@ -124,7 +124,7 @@ export const BackupHistory = () => {
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
||||
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="completed">Completed</option>
|
||||
@@ -149,33 +149,33 @@ export const BackupHistory = () => {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.dateTime')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.size')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.duration')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{t('backup.history.columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{backups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-lg font-medium">No backups found</p>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||
<p className="text-lg font-medium text-neutral-900 dark:text-neutral-100">No backups found</p>
|
||||
<p className="text-sm mt-1">Backups will appear here once created</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -188,40 +188,40 @@ export const BackupHistory = () => {
|
||||
|
||||
return (
|
||||
<React.Fragment key={backup.id}>
|
||||
<tr className="hover:bg-gray-50">
|
||||
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
|
||||
<span className="ml-2 text-sm font-medium text-gray-900 capitalize">
|
||||
<span className="ml-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 capitalize">
|
||||
{backup.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{format(new Date(backup.created_at), 'PPP')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{format(new Date(backup.created_at), 'p')} • {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 capitalize">
|
||||
{backup.backup_type || 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-sm text-gray-900">
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{formatBytes(stats.total_size || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{backup.duration_seconds
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{backup.duration_seconds
|
||||
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
|
||||
: '-'}
|
||||
</td>
|
||||
@@ -229,7 +229,7 @@ export const BackupHistory = () => {
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => toggleRowExpansion(backup.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
title={t('backup.actions.view')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
@@ -237,7 +237,7 @@ export const BackupHistory = () => {
|
||||
{backup.manifest_path && (
|
||||
<button
|
||||
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
title={t('backup.actions.download')}
|
||||
>
|
||||
<Download size={20} />
|
||||
@@ -245,7 +245,7 @@ export const BackupHistory = () => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(backup)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
className="text-neutral-400 hover:text-red-600"
|
||||
title={t('backup.actions.delete')}
|
||||
disabled={deleteMutation.isLoading}
|
||||
>
|
||||
@@ -258,24 +258,24 @@ export const BackupHistory = () => {
|
||||
{/* Expanded Details Row */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 bg-gray-50">
|
||||
<td colSpan={6} className="px-6 py-4 bg-neutral-50 dark:bg-neutral-700/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Backup Details */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.backupDetails')}</h4>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.backupDetails')}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t('backup.history.details.destination')}:</span>
|
||||
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.destination')}:</span>
|
||||
<span className="text-neutral-900 dark:text-neutral-100">{backup.destination_type || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t('backup.history.details.started')}:</span>
|
||||
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
|
||||
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.created_at), 'p')}</span>
|
||||
</div>
|
||||
{backup.completed_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t('backup.history.details.completed')}:</span>
|
||||
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
|
||||
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.completed_at), 'p')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -283,21 +283,21 @@ export const BackupHistory = () => {
|
||||
|
||||
{/* Content Backed Up */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.contentBackedUp')}</h4>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.contentBackedUp')}</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm text-gray-700">
|
||||
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm text-gray-700">
|
||||
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Archives ({stats.archives_backed_up || 0})
|
||||
</span>
|
||||
</div>
|
||||
@@ -307,8 +307,8 @@ export const BackupHistory = () => {
|
||||
{/* Error Information */}
|
||||
{backup.error_message && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-red-900">{t('backup.history.details.errorDetails')}</h4>
|
||||
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
||||
<h4 className="font-medium text-red-900 dark:text-red-200">{t('backup.history.details.errorDetails')}</h4>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 p-2 rounded">
|
||||
{backup.error_message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -317,8 +317,8 @@ export const BackupHistory = () => {
|
||||
{/* Manifest Path */}
|
||||
{backup.manifest_path && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.manifest')}</h4>
|
||||
<p className="text-sm text-gray-600 font-mono break-all">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.manifest')}</h4>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 font-mono break-all">
|
||||
{backup.manifest_path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ export const BackupHistory = () => {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||
<div className="bg-white dark:bg-neutral-800 px-4 py-3 border-t border-neutral-200 dark:border-neutral-700 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<Button
|
||||
@@ -359,7 +359,7 @@ export const BackupHistory = () => {
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('backup.history.pagination.showing', {
|
||||
from: (currentPage - 1) * pagination.limit + 1,
|
||||
to: Math.min(currentPage * pagination.limit, pagination.total),
|
||||
@@ -372,11 +372,11 @@ export const BackupHistory = () => {
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('backup.history.pagination.previous')}
|
||||
</button>
|
||||
|
||||
|
||||
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
@@ -385,19 +385,19 @@ export const BackupHistory = () => {
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? 'z-10 bg-primary-50 border-primary text-primary'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary text-primary'
|
||||
: 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
||||
disabled={currentPage === pagination.pages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('backup.history.pagination.next')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FolderOpen, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card } from '../common';
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BulkCategoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (categoryId: number | null) => Promise<void>;
|
||||
photoCount: number;
|
||||
categories: CategoryOption[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
photoCount,
|
||||
categories,
|
||||
isLoading,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm(selectedCategoryId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedCategoryId(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="category-select" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('photos.selectCategory', 'Select category')}
|
||||
</label>
|
||||
<select
|
||||
id="category-select"
|
||||
value={selectedCategoryId ?? ''}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||
>
|
||||
{t('photos.movePhotos', 'Move Photos')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BulkCategoryModal.displayName = 'BulkCategoryModal';
|
||||
@@ -101,7 +101,7 @@ export const CategoryManager: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">{t('categories.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -116,14 +116,14 @@ export const CategoryManager: React.FC = () => {
|
||||
|
||||
{/* Add new category form */}
|
||||
{isAdding && (
|
||||
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
|
||||
<div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder={t('categories.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
@@ -154,14 +154,14 @@ export const CategoryManager: React.FC = () => {
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-neutral-500 text-center py-8">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
|
||||
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
||||
>
|
||||
{editingId === category.id ? (
|
||||
<div className="flex gap-2 flex-1">
|
||||
@@ -173,7 +173,7 @@ export const CategoryManager: React.FC = () => {
|
||||
if (e.key === 'Enter') handleUpdate(category.id);
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
@@ -199,20 +199,20 @@ export const CategoryManager: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500">/{category.slug}</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title={t('common.delete')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../common';
|
||||
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
||||
|
||||
@@ -92,14 +92,14 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Code className="w-5 h-5" />
|
||||
{t('cssTemplates.title', 'Custom CSS Templates')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-neutral-200 mb-6">
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||
{[1, 2, 3].map(slot => {
|
||||
const template = localTemplates.find(t => t.slot_number === slot);
|
||||
return (
|
||||
@@ -109,7 +109,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeSlot === slot
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
|
||||
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('cssTemplates.template', 'Template')} {slot}
|
||||
@@ -130,7 +130,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Template Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
||||
{t('cssTemplates.templateName', 'Template Name')}
|
||||
</label>
|
||||
<input
|
||||
@@ -138,7 +138,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
value={activeTemplate.name}
|
||||
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
|
||||
maxLength={50}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,18 +151,18 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mt-1 ml-6">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CSS Editor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
||||
{t('cssTemplates.cssContent', 'CSS Content')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -177,22 +177,22 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-amber-800">
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-amber-800 dark:text-amber-200">
|
||||
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
|
||||
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
{activeSlot === 1 && activeTemplate.is_default && (
|
||||
<Button
|
||||
@@ -227,7 +227,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
|
||||
{/* Last Updated */}
|
||||
{activeTemplate.updated_at && (
|
||||
<p className="text-xs text-neutral-400 text-right">
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
|
||||
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -25,27 +25,27 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-6 h-6 text-primary-600" />
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
|
||||
<p className="text-sm font-medium text-neutral-600">Subject:</p>
|
||||
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
|
||||
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">Subject:</p>
|
||||
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mt-1">{subject}</p>
|
||||
</div>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="px-6 py-3 border-b border-neutral-200">
|
||||
<div className="px-6 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'html' ? 'primary' : 'outline'}
|
||||
@@ -71,7 +71,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{viewMode === 'html' ? (
|
||||
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
|
||||
<div className="bg-white border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm">
|
||||
<iframe
|
||||
srcDoc={htmlContent}
|
||||
className="w-full h-[600px] border-0"
|
||||
@@ -79,8 +79,8 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
@@ -15,6 +16,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const { t } = useTranslation();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
// Fetch photos for hero selection
|
||||
const { data: photos = [] } = useQuery({
|
||||
queryKey: ['admin-event-photos', eventId, {}],
|
||||
queryFn: () => photosService.getEventPhotos(eventId, {}),
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
event_id: eventId
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
@@ -56,6 +65,20 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
},
|
||||
});
|
||||
|
||||
// Set hero photo mutation
|
||||
const heroMutation = useMutation({
|
||||
mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
|
||||
categoriesService.setCategoryHeroPhoto(categoryId, photoId),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
setHeroPickerCategoryId(null);
|
||||
toast.success(variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('categories.failedToSetCoverPhoto'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -68,6 +91,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
|
||||
heroMutation.mutate({ categoryId, photoId });
|
||||
};
|
||||
|
||||
const handleRemoveHeroPhoto = (categoryId: number) => {
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -79,7 +110,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -92,6 +123,11 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
|
||||
{/* Add new category form */}
|
||||
{isAdding && (
|
||||
<div className="flex gap-2">
|
||||
@@ -101,7 +137,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder={t('categories.categoryName')}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
@@ -131,49 +167,176 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{eventCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
|
||||
>
|
||||
<span className="text-sm text-neutral-700">{category.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
<div className="space-y-2">
|
||||
{eventCategories.map((category) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={category.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : category.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-primary-400" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="space-y-2">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => (
|
||||
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
.map(cat => {
|
||||
const heroPhoto = cat.hero_photo_id
|
||||
? photos.find(p => p.id === cat.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={cat.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : cat.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-primary-400" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{heroPickerCategoryId !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.setCoverPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(null)}
|
||||
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
|
||||
{t('events.noPhotosAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => {
|
||||
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
|
||||
: 'border-transparent hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-100 dark:bg-neutral-700">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
|
||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
disabled={heroMutation.isPending}
|
||||
>
|
||||
{t('categories.removeCoverPhoto')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setHeroPickerCategoryId(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EventCategoryManager.displayName = 'EventCategoryManager';
|
||||
EventCategoryManager.displayName = 'EventCategoryManager';
|
||||
|
||||
@@ -28,7 +28,7 @@ interface EventRenameDialogProps {
|
||||
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
||||
isOpen,
|
||||
eventName,
|
||||
eventId,
|
||||
eventId: _eventId,
|
||||
customerEmail,
|
||||
onClose,
|
||||
onRename,
|
||||
|
||||
@@ -51,7 +51,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
<Card className={className}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
||||
</h2>
|
||||
@@ -62,7 +62,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
onChange={() => handleToggle('feedback_enabled')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||
</span>
|
||||
</label>
|
||||
@@ -72,77 +72,77 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
<>
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_ratings}
|
||||
onChange={() => handleToggle('allow_ratings')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Star className="w-5 h-5 text-neutral-600" />
|
||||
<Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_likes}
|
||||
onChange={() => handleToggle('allow_likes')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Heart className="w-5 h-5 text-neutral-600" />
|
||||
<Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.likes', 'Likes')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_comments}
|
||||
onChange={() => handleToggle('allow_comments')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
||||
<MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.comments', 'Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_favorites}
|
||||
onChange={() => handleToggle('allow_favorites')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Bookmark className="w-5 h-5 text-neutral-600" />
|
||||
<Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.favorites', 'Favorites')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,11 +150,11 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Privacy & Moderation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -166,10 +166,10 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,12 +183,12 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
disabled={!settings.allow_comments}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
<Shield className="w-5 h-5 text-neutral-600" />
|
||||
<Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,12 +201,12 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
onChange={() => handleToggle('show_feedback_to_guests')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Eye className="w-5 h-5 text-neutral-600" />
|
||||
<Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
@@ -226,10 +226,10 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +238,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
{settings.enable_rate_limiting && (
|
||||
<div className="grid grid-cols-2 gap-4 ml-7">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
||||
</label>
|
||||
<input
|
||||
@@ -247,11 +247,11 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
max="60"
|
||||
value={settings.rate_limit_window_minutes || 15}
|
||||
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
{t('feedback.settings.maxRequests', 'Max Requests')}
|
||||
</label>
|
||||
<input
|
||||
@@ -260,7 +260,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
max="100"
|
||||
value={settings.rate_limit_max_requests || 10}
|
||||
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticatedImage, Button } from '../common';
|
||||
|
||||
interface FocalPointPickerProps {
|
||||
imageUrl: string;
|
||||
currentValue: string;
|
||||
onChange: (value: string) => void;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
/** Convert legacy keyword to percentage pair */
|
||||
const keywordToPercent = (value: string): string => {
|
||||
switch (value) {
|
||||
case 'top': return '50% 0%';
|
||||
case 'center': return '50% 50%';
|
||||
case 'bottom': return '50% 100%';
|
||||
default: return value || '50% 50%';
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse an anchor value (keyword or "X% Y%") into [x, y] numbers 0-100 */
|
||||
const parseAnchor = (value: string): [number, number] => {
|
||||
const pct = keywordToPercent(value);
|
||||
const match = pct.match(/^(\d{1,3})%\s+(\d{1,3})%$/);
|
||||
if (match) return [parseInt(match[1]), parseInt(match[2])];
|
||||
return [50, 50];
|
||||
};
|
||||
|
||||
export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
||||
imageUrl,
|
||||
currentValue,
|
||||
onChange,
|
||||
slug,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [x, y] = parseAnchor(currentValue);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const px = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)));
|
||||
const py = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)));
|
||||
onChange(`${px}% ${py}%`);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const presets: { label: string; value: string }[] = [
|
||||
{ label: t('events.heroImageAnchorTop', 'Top'), value: '50% 0%' },
|
||||
{ label: t('events.heroImageAnchorCenter', 'Center'), value: '50% 50%' },
|
||||
{ label: t('events.heroImageAnchorBottom', 'Bottom'), value: '50% 100%' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Clickable image preview */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="relative w-full h-48 rounded-lg overflow-hidden cursor-crosshair border border-neutral-300"
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={imageUrl}
|
||||
alt={t('events.heroPreview', 'Hero preview')}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
slug={slug}
|
||||
/>
|
||||
|
||||
{/* Crosshair marker */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Outer ring (dark) for contrast on light areas */}
|
||||
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
|
||||
{/* Inner ring (white) for contrast on dark areas */}
|
||||
<div className="absolute inset-0 m-px w-6 h-6 rounded-full border-2 border-white" />
|
||||
{/* Center dot */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coordinate label */}
|
||||
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[10px] font-mono leading-none text-white bg-black/60 rounded">
|
||||
{x}% {y}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{presets.map((p) => (
|
||||
<Button
|
||||
key={p.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(p.value)}
|
||||
className={
|
||||
keywordToPercent(currentValue) === p.value
|
||||
? 'bg-primary-50 border-primary-300 text-primary-700'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FocalPointPicker.displayName = 'FocalPointPicker';
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
import { Camera, Calendar } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GalleryPreviewBranding {
|
||||
company_name?: string;
|
||||
@@ -64,12 +65,13 @@ const PreviewPhoto: React.FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
theme,
|
||||
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
theme,
|
||||
branding,
|
||||
layoutType,
|
||||
className = ''
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||
|
||||
// Use the provided layoutType or fallback to theme's gallery layout
|
||||
@@ -92,6 +94,41 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
? 'justify-end text-right flex-row-reverse'
|
||||
: 'justify-start text-left';
|
||||
|
||||
// Check header style
|
||||
const isHeroHeader = theme.headerStyle === 'hero';
|
||||
const isMinimalHeader = theme.headerStyle === 'minimal';
|
||||
const isNoHeader = theme.headerStyle === 'none';
|
||||
const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave';
|
||||
|
||||
// Render hero divider based on style
|
||||
const renderHeroDivider = () => {
|
||||
const bgColor = theme.backgroundColor || '#fafafa';
|
||||
switch (heroDividerStyle) {
|
||||
case 'wave':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'curve':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,120 Q600,0 1200,120 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'angle':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,120 L600,40 L1200,120 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'straight':
|
||||
case 'none':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderLayout = () => {
|
||||
const spacing = theme.gallerySettings?.spacing || 'normal';
|
||||
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
||||
@@ -151,18 +188,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'hero':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
|
||||
<div className={`grid grid-cols-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(1, 5).map((photo) => (
|
||||
<PreviewPhoto key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'mosaic':
|
||||
return (
|
||||
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
|
||||
@@ -181,7 +206,7 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
|
||||
style={{
|
||||
backgroundColor: theme.backgroundColor || '#ffffff',
|
||||
@@ -189,45 +214,124 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
fontFamily: theme.fontFamily || 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b space-y-2"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||
{showLogo && (
|
||||
resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showText && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||
{brandTagline && (
|
||||
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||
{/* Hero Header */}
|
||||
{isHeroHeader && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || theme.primaryColor || '#22c55e',
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23ffffff\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M0 40L40 0H20L0 20M40 40V20L20 40\'/%3E%3C/g%3E%3C/svg%3E")',
|
||||
}}
|
||||
>
|
||||
<div className="py-8 px-4 relative z-10">
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
{showLogo && (
|
||||
<div className="mb-3">
|
||||
{resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-10 w-auto object-contain mx-auto"
|
||||
style={{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center mx-auto">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className="text-xl font-bold mb-2"
|
||||
style={{
|
||||
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
Sample Event
|
||||
</h1>
|
||||
<div className="flex items-center justify-center text-white/80 text-sm" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span>January 15, 2026</span>
|
||||
</div>
|
||||
{/* Hero photo placeholder hint */}
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-white/60 text-xs">
|
||||
<Camera className="w-3 h-3" />
|
||||
<span>{t('branding.heroPlaceholderText')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
{renderHeroDivider()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 flex justify-between">
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{activeLayout} layout</span>
|
||||
)}
|
||||
|
||||
{/* Standard Header */}
|
||||
{!isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
<div
|
||||
className="px-4 py-3 border-b space-y-2"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||
{showLogo && (
|
||||
resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showText && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||
{brandTagline && (
|
||||
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimal Header - thin bar with just event name */}
|
||||
{isMinimalHeader && (
|
||||
<div
|
||||
className="px-4 py-2 border-b"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold truncate"
|
||||
style={{
|
||||
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
Sample Event
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* None Header - no header content at all */}
|
||||
{/* (isNoHeader renders nothing here — goes straight to layout bar) */}
|
||||
|
||||
{/* Layout info bar */}
|
||||
<div className="px-4 py-1 border-b text-xs text-neutral-500 flex justify-between" style={{ borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb' }}>
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : isMinimalHeader ? `Minimal + ${activeLayout}` : isNoHeader ? `No header + ${activeLayout}` : `${activeLayout} layout`}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{renderLayout()}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
{currentHeroPhoto ? (
|
||||
@@ -57,10 +57,10 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.heroPhotoHelp')}
|
||||
</p>
|
||||
|
||||
@@ -107,12 +107,12 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.selectHeroPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -157,7 +157,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
|
||||
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
|
||||
@@ -103,10 +103,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('passwordChange.title')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
@@ -115,7 +115,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('passwordChange.currentPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -131,7 +131,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||
>
|
||||
{showPasswords.current ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
@@ -143,7 +143,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('passwordChange.newPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -159,7 +159,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||
>
|
||||
{showPasswords.new ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
@@ -171,7 +171,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('passwordChange.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -187,7 +187,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||
>
|
||||
{showPasswords.confirm ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
@@ -198,10 +198,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>{t('passwordChange.minLength')}</li>
|
||||
|
||||
@@ -75,7 +75,20 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
if (selectedPhotoIds.length > 0) {
|
||||
options.photo_ids = selectedPhotoIds;
|
||||
} else if (filters) {
|
||||
options.filter = filters;
|
||||
// Convert camelCase filter keys to snake_case for backend
|
||||
options.filter = {
|
||||
min_rating: filters.minRating,
|
||||
max_rating: filters.maxRating,
|
||||
has_likes: filters.hasLikes,
|
||||
min_likes: filters.minLikes,
|
||||
has_favorites: filters.hasFavorites,
|
||||
min_favorites: filters.minFavorites,
|
||||
has_comments: filters.hasComments,
|
||||
category_id: filters.categoryId,
|
||||
logic: filters.logic,
|
||||
sort: filters.sort,
|
||||
order: filters.order,
|
||||
};
|
||||
}
|
||||
|
||||
exportMutation.mutate(options);
|
||||
@@ -101,8 +114,8 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
||||
transition-colors
|
||||
${isDisabled
|
||||
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
|
||||
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 border-neutral-200 dark:border-neutral-700 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -129,9 +142,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
/>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
|
||||
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 z-20">
|
||||
<div className="p-2">
|
||||
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
<p className="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{hasSelection
|
||||
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
|
||||
: t('export.exportFiltered', 'Export filtered photos')
|
||||
@@ -145,14 +158,14 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
key={format.value}
|
||||
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
|
||||
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 text-left transition-colors"
|
||||
>
|
||||
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
|
||||
<Icon className="w-5 h-5 text-neutral-500 dark:text-neutral-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{format.label}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{format.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +178,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
)}
|
||||
|
||||
{!hasSelection && !hasFilters && (
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('export.hint', 'Select photos or apply filters to export')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -57,9 +57,9 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
filters.hasComments;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-neutral-900 flex items-center gap-2">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('filter.feedbackFilters', 'Feedback Filters')}
|
||||
</h3>
|
||||
@@ -78,19 +78,19 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
<div className="space-y-4">
|
||||
{/* Rating Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<Star className="w-4 h-4 inline mr-1" />
|
||||
{t('filter.rating', 'Rating')}
|
||||
</label>
|
||||
<select
|
||||
value={filters.minRating ?? ''}
|
||||
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{RATING_OPTIONS.map(option => (
|
||||
<option key={option.label} value={option.value ?? ''}>
|
||||
{t(option.label, option.label.split('.').pop())}
|
||||
{t(option.label, { defaultValue: option.label.split('.').pop() })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -107,10 +107,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Heart className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-neutral-700">
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('filter.hasLikes', 'Has likes')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 ml-1">({summary.withLikes})</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withLikes})</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
@@ -124,10 +124,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Bookmark className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-sm text-neutral-700">
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('filter.hasFavorites', 'Has favorites')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 ml-1">({summary.withFavorites})</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withFavorites})</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
@@ -141,10 +141,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MessageCircle className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-neutral-700">
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('filter.hasComments', 'Has comments')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 ml-1">({summary.withComments})</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withComments})</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
@@ -153,15 +153,15 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
{/* Logic Toggle */}
|
||||
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
|
||||
<div className="flex rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('filter.combineWith', 'Combine with')}:</span>
|
||||
<div className="flex rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLogicChange('AND')}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||
filters.logic === 'AND' || !filters.logic
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
@@ -173,7 +173,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||
filters.logic === 'OR'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
@@ -185,7 +185,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
|
||||
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
|
||||
{summary.withRatings > 0 && (
|
||||
<span className="ml-2">
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
|
||||
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
@@ -60,7 +60,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
const numeric = Number(raw);
|
||||
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
|
||||
}}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
|
||||
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
@@ -78,7 +78,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<select
|
||||
value={mediaType}
|
||||
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="all">{t('gallery.allMedia', 'All media')}</option>
|
||||
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
|
||||
@@ -92,7 +92,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
|
||||
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
||||
@@ -102,13 +102,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
className="p-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
||||
<SortAsc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
) : (
|
||||
<SortDesc className="w-5 h-5 text-neutral-600" />
|
||||
<SortDesc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user